From d0370d135d60c4706b3b657c948172c84b4eb42e Mon Sep 17 00:00:00 2001 From: Anton Tkachev Date: Tue, 9 Jan 2024 17:06:59 +0300 Subject: [PATCH 1/2] AND-5746 [Solana] Improved fee calculation logic --- .idea/codeStyles/Project.xml | 2 +- .../blockchains/solana/RentProvider.kt | 2 + .../blockchains/solana/ResultExt.kt | 19 + .../solana/SolanaNetworkService.kt | 194 +++---- .../solana/SolanaRpcClientBuilder.kt | 32 +- .../solana/SolanaTokenAccountInfoFinder.kt | 60 +++ .../solana/SolanaTransactionBuilder.kt | 167 ++++++ .../solana/SolanaValueConverter.kt | 21 + .../blockchains/solana/SolanaWalletManager.kt | 483 ++++++------------ .../solana/solanaj/core/PublicKeyExt.kt | 6 +- .../core/{Message.kt => SolanaMessage.kt} | 2 +- .../{Transaction.kt => SolanaTransaction.kt} | 8 +- .../solana/solanaj/model/FeeInfo.java | 23 + .../solanaj/model/SolanaAccountInfo.java | 42 ++ .../solanaj/model/SolanaMainAccountInfo.kt | 27 + .../solana/solanaj/model/TransactionInfo.kt | 9 + ...enProgramId.kt => SolanaTokenProgramId.kt} | 2 +- .../solanaj/program/TokenInstructions.kt | 4 +- .../rpc/{RpcApi.kt => SolanaRpcApi.kt} | 45 +- .../rpc/{RpcClient.kt => SolanaRpcClient.kt} | 7 +- .../blockchain/extensions/Coroutines.kt | 7 + 21 files changed, 676 insertions(+), 486 deletions(-) create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTokenAccountInfoFinder.kt create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTransactionBuilder.kt create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaValueConverter.kt rename blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/{Message.kt => SolanaMessage.kt} (94%) rename blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/{Transaction.kt => SolanaTransaction.kt} (84%) create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/FeeInfo.java create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaAccountInfo.java create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaMainAccountInfo.kt create mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/TransactionInfo.kt rename blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/{TokenProgramId.kt => SolanaTokenProgramId.kt} (80%) rename blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/{RpcApi.kt => SolanaRpcApi.kt} (50%) rename blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/{RpcClient.kt => SolanaRpcClient.kt} (63%) diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 527919417..4b43997f3 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -201,4 +201,4 @@ - \ No newline at end of file + diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/RentProvider.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/RentProvider.kt index b08cf4443..24fd7ccb6 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/RentProvider.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/RentProvider.kt @@ -7,6 +7,8 @@ import java.math.BigDecimal * Created by Anton Zhilenkov on 31/01/2022. */ interface RentProvider { + suspend fun minimalBalanceForRentExemption(): Result + suspend fun rentAmount(): BigDecimal } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt new file mode 100644 index 000000000..a6b029da8 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt @@ -0,0 +1,19 @@ +package com.tangem.blockchain.blockchains.solana + +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.common.CompletionResult + +internal fun Result.toSimpleResult(): SimpleResult { + return when (this) { + is Result.Success -> SimpleResult.Success + is Result.Failure -> SimpleResult.Failure(this.error) + } +} + +internal inline fun CompletionResult.successOr(failureClause: (CompletionResult.Failure) -> Nothing): T { + return when (this) { + is CompletionResult.Success -> this.data + is CompletionResult.Failure -> failureClause(this) + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaNetworkService.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaNetworkService.kt index 5ca9f853d..19fcf1408 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaNetworkService.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaNetworkService.kt @@ -1,8 +1,10 @@ package com.tangem.blockchain.blockchains.solana -import com.tangem.blockchain.blockchains.solana.solanaj.core.Transaction -import com.tangem.blockchain.blockchains.solana.solanaj.program.TokenProgramId -import com.tangem.blockchain.blockchains.solana.solanaj.rpc.RpcClient +import com.tangem.blockchain.blockchains.solana.solanaj.core.SolanaTransaction +import com.tangem.blockchain.blockchains.solana.solanaj.model.* +import com.tangem.blockchain.blockchains.solana.solanaj.program.SolanaTokenProgramId +import com.tangem.blockchain.blockchains.solana.solanaj.rpc.SolanaRpcClient +import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.BlockchainSdkError.Solana import com.tangem.blockchain.common.NetworkProvider import com.tangem.blockchain.extensions.Result @@ -12,23 +14,23 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import org.p2p.solanaj.core.PublicKey -import org.p2p.solanaj.rpc.Cluster -import org.p2p.solanaj.rpc.types.* +import org.p2p.solanaj.rpc.types.SignatureStatuses +import org.p2p.solanaj.rpc.types.TokenAccountInfo import org.p2p.solanaj.rpc.types.config.Commitment -import java.math.BigDecimal /** * Created by Anton Zhilenkov on 26/01/2022. */ // FIXME: Refactor with wallet-core: https://tangem.atlassian.net/browse/AND-5706 -class SolanaNetworkService( - private val provider: RpcClient, +internal class SolanaNetworkService( + private val provider: SolanaRpcClient, ) : NetworkProvider { override val baseUrl: String = provider.host + val endpoint: String = provider.endpoint suspend fun getMainAccountInfo(account: PublicKey): Result = withContext(Dispatchers.IO) { - val accountInfo = accountInfo(account).successOr { return@withContext it } + val accountInfo = getAccountInfo(account).successOr { return@withContext it } val tokenAccounts = accountTokensInfo(account).successOr { return@withContext it } val tokensByMint = tokenAccounts.map { @@ -43,7 +45,7 @@ class SolanaNetworkService( val txsInProgress = getTransactionsInProgressInfo(account).successOr { listOf() } Result.Success( SolanaMainAccountInfo( - value = accountInfo.value, + value = accountInfo, tokensByMint = tokensByMint, txsInProgress = txsInProgress, ), @@ -82,11 +84,49 @@ class SolanaNetworkService( } } - private suspend fun accountInfo(account: PublicKey): Result = withContext(Dispatchers.IO) { - try { - Result.Success(provider.api.getAccountInfo(account, Commitment.FINALIZED.toMap())) - } catch (ex: Exception) { - Result.Failure(Solana.Api(ex)) + suspend fun getAccountInfoIfExist(account: PublicKey): Result { + return withContext(Dispatchers.IO) { + try { + val accountInfo = getAccountInfo(account) + .successOr { return@withContext it } + + if (accountInfo == null) { + Result.Failure(BlockchainSdkError.AccountNotFound) + } else { + Result.Success(accountInfo) + } + } catch (ex: Exception) { + Result.Failure(Solana.Api(ex)) + } + } + } + + suspend fun getTokenAccountInfoIfExist(associatedAccount: PublicKey): Result { + return withContext(Dispatchers.IO) { + try { + val splAccountInfo = provider.api.getSplTokenAccountInfo(associatedAccount) + + if (splAccountInfo.value == null) { + Result.Failure(BlockchainSdkError.AccountNotFound) + } else { + Result.Success(SolanaSplAccountInfo(splAccountInfo.value, associatedAccount)) + } + } catch (ex: Exception) { + Result.Failure(Solana.Api(ex)) + } + } + } + + private suspend fun getAccountInfo(account: PublicKey): Result { + return withContext(Dispatchers.IO) { + try { + val params = mapOf("commitment" to Commitment.FINALIZED) + val accountInfo = provider.api.getAccountInfoNew(account, params) + + Result.Success(accountInfo.value) + } catch (ex: Exception) { + Result.Failure(Solana.Api(ex)) + } } } @@ -94,10 +134,10 @@ class SolanaNetworkService( withContext(Dispatchers.IO) { try { val tokensAccountsInfoDefault = async { - tokenAccountInfo(account, TokenProgramId.TOKEN.value) + tokenAccountInfo(account, SolanaTokenProgramId.TOKEN.value) } val tokensAccountsInfo2022 = async { - tokenAccountInfo(account, TokenProgramId.TOKEN_2022.value) + tokenAccountInfo(account, SolanaTokenProgramId.TOKEN_2022.value) } val tokensAccountsInfo = awaitAll(tokensAccountsInfoDefault, tokensAccountsInfo2022) @@ -111,71 +151,33 @@ class SolanaNetworkService( } private fun tokenAccountInfo(account: PublicKey, programId: PublicKey): TokenAccountInfo { - val params = mutableMapOf("programId" to programId) - .apply { addCommitment(Commitment.RECENT) } + val params = buildMap { + put("programId", programId) + put("commitment", Commitment.RECENT.value) + } return provider.api.getTokenAccountsByOwner(account, params, mutableMapOf()) } - private suspend fun splAccountInfo(associatedAccount: PublicKey): Result = - withContext(Dispatchers.IO) { - try { - val splAccountInfo = provider.api.getSplTokenAccountInfo(associatedAccount) - Result.Success(SolanaSplAccountInfo(splAccountInfo.value, associatedAccount)) - } catch (ex: Exception) { - Result.Failure(Solana.Api(ex)) - } - } - - suspend fun getFees(): Result = withContext(Dispatchers.IO) { + suspend fun getFeeForMessage(transaction: SolanaTransaction): Result = withContext(Dispatchers.IO) { try { - val params = provider.api.getFees(Commitment.FINALIZED) + val params = provider.api.getFeeForMessage(transaction, Commitment.PROCESSED) Result.Success(params) } catch (ex: Exception) { Result.Failure(Solana.Api(ex)) } } - suspend fun isAccountExist(account: PublicKey): Result = withContext(Dispatchers.IO) { - val info = accountInfo(account).successOr { return@withContext it } - Result.Success(info.accountExist) - } - - suspend fun isTokenAccountExist(associatedAccount: PublicKey): Result { - return withContext(Dispatchers.IO) { - val info = splAccountInfo(associatedAccount).successOr { return@withContext it } - - Result.Success(info.accountExist) - } - } - - fun mainAccountCreationFee(): BigDecimal = accountRentFeeByEpoch(1) - - suspend fun tokenAccountCreationFee(): Result = minimalBalanceForRentExemption(BUFFER_LENGTH) - - internal fun accountRentFeeByEpoch(numberOfEpochs: Int = 1): BigDecimal { - // https://docs.solana.com/developing/programming-model/accounts#calculation-of-rent - // result in lamports - val minimumAccountSizeInBytes = BigDecimal(MIN_ACCOUNT_SIZE) - - val rentInLamportPerByteEpoch = BigDecimal(determineRentPerByteEpoch(provider.endpoint)) - val rentFeePerEpoch = minimumAccountSizeInBytes - .multiply(numberOfEpochs.toBigDecimal()) - .multiply(rentInLamportPerByteEpoch) - - return rentFeePerEpoch - } - - suspend fun minimalBalanceForRentExemption(dataLength: Long = 0): Result = withContext(Dispatchers.IO) { + suspend fun minimalBalanceForRentExemption(dataLength: Long): Result = withContext(Dispatchers.IO) { try { val rent = provider.api.getMinimumBalanceForRentExemption(dataLength) - Result.Success(rent.toBigDecimal()) + Result.Success(rent) } catch (ex: Exception) { Result.Failure(Solana.Api(ex)) } } - suspend fun sendTransaction(signedTransaction: Transaction): Result = withContext(Dispatchers.IO) { + suspend fun sendTransaction(signedTransaction: SolanaTransaction): Result = withContext(Dispatchers.IO) { try { val result = provider.api.sendSignedTransaction(signedTransaction) Result.Success(result) @@ -191,68 +193,4 @@ class SolanaNetworkService( Result.Failure(Solana.Api(ex)) } } - - private fun determineRentPerByteEpoch(endpoint: String): Double = when (endpoint) { - Cluster.TESTNET.endpoint -> RENT_PER_BYTE_EPOCH - Cluster.DEVNET.endpoint -> RENT_PER_BYTE_EPOCH_DEV_NET - else -> RENT_PER_BYTE_EPOCH - } - - companion object { - const val MIN_ACCOUNT_SIZE = 128L - const val RENT_PER_BYTE_EPOCH = 19.055441478439427 - const val RENT_PER_BYTE_EPOCH_DEV_NET = 0.359375 - const val BUFFER_LENGTH = 165L - } -} - -private val AccountInfo.accountExist - get() = value != null - -data class SolanaMainAccountInfo( - val value: AccountInfo.Value?, - val tokensByMint: Map, - val txsInProgress: List, -) { - val balance: Long - get() = value?.lamports ?: 0L - - val accountExist: Boolean - get() = value != null - - val requireValue: AccountInfo.Value - get() = value!! -} - -data class SolanaSplAccountInfo( - val value: TokenResultObjects.Value?, - val associatedPubK: PublicKey, -) { - val accountExist: Boolean - get() = value != null - - val requireValue: TokenResultObjects.Value - get() = value!! -} - -data class SolanaTokenAccountInfo( - val value: TokenAccountInfo.Value, - val address: String, - val mint: String, - val uiAmount: BigDecimal, // in SOL -) - -data class TransactionInfo( - val signature: String, - val fee: Long, // in lamports - val instructions: List, -) - -private fun MutableMap.addCommitment(commitment: Commitment): MutableMap { - this["commitment"] = commitment - return this -} - -private fun Commitment.toMap(): MutableMap { - return mutableMapOf("commitment" to this) } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaRpcClientBuilder.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaRpcClientBuilder.kt index 1d4d31a65..bf0eedac3 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaRpcClientBuilder.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaRpcClientBuilder.kt @@ -1,6 +1,6 @@ package com.tangem.blockchain.blockchains.solana -import com.tangem.blockchain.blockchains.solana.solanaj.rpc.RpcClient +import com.tangem.blockchain.blockchains.solana.solanaj.rpc.SolanaRpcClient import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.common.GetBlockCredentials import com.tangem.blockchain.common.NowNodeCredentials @@ -12,9 +12,9 @@ import org.p2p.solanaj.rpc.Cluster /** * Created by Anton Zhilenkov on 06.02.2023. */ -class SolanaRpcClientBuilder { +internal class SolanaRpcClientBuilder { - fun build(isTestnet: Boolean, config: BlockchainSdkConfig): List { + fun build(isTestnet: Boolean, config: BlockchainSdkConfig): List { return if (isTestnet) { listOf(devNet()) } else { @@ -26,20 +26,20 @@ class SolanaRpcClientBuilder { } } - private fun mainNet(): RpcClient = RpcClient(Cluster.MAINNET.endpoint) + private fun mainNet(): SolanaRpcClient = SolanaRpcClient(Cluster.MAINNET.endpoint) - private fun devNet(): RpcClient = RpcClient(Cluster.DEVNET.endpoint) + private fun devNet(): SolanaRpcClient = SolanaRpcClient(Cluster.DEVNET.endpoint) @Suppress("UnusedPrivateMember") - private fun testNet(): RpcClient = RpcClient(Cluster.TESTNET.endpoint) + private fun testNet(): SolanaRpcClient = SolanaRpcClient(Cluster.TESTNET.endpoint) - private fun quickNode(cred: QuickNodeCredentials): RpcClient { + private fun quickNode(cred: QuickNodeCredentials): SolanaRpcClient { val host = "https://${cred.subdomain}.solana-mainnet.discover.quiknode.pro/${cred.apiKey}" - return RpcClient(host) + return SolanaRpcClient(host) } - private fun nowNode(cred: NowNodeCredentials): RpcClient { - return RpcClient( + private fun nowNode(cred: NowNodeCredentials): SolanaRpcClient { + return SolanaRpcClient( host = "https://sol.nownodes.io", httpInterceptors = createInterceptor(NowNodeCredentials.headerApiKey, cred.apiKey), ) @@ -47,20 +47,20 @@ class SolanaRpcClientBuilder { // contains old data about 7 hours @Suppress("UnusedPrivateMember") - private fun ankr(): RpcClient { - return RpcClient("https://rpc.ankr.com/solana") + private fun ankr(): SolanaRpcClient { + return SolanaRpcClient(host = "https://rpc.ankr.com/solana") } // unstable @Suppress("UnusedPrivateMember") - private fun getBlock(cred: GetBlockCredentials): RpcClient { - return RpcClient(host = "https://go.getblock.io/${cred.solana}") + private fun getBlock(cred: GetBlockCredentials): SolanaRpcClient { + return SolanaRpcClient(host = "https://go.getblock.io/${cred.solana}") } // zero uptime @Suppress("UnusedPrivateMember") - private fun projectserum(): RpcClient { - return RpcClient(host = "https://solana-api.projectserum.com") + private fun projectserum(): SolanaRpcClient { + return SolanaRpcClient(host = "https://solana-api.projectserum.com") } private fun createInterceptor(key: String, value: String): List { diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTokenAccountInfoFinder.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTokenAccountInfoFinder.kt new file mode 100644 index 000000000..9d94b6aba --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTokenAccountInfoFinder.kt @@ -0,0 +1,60 @@ +package com.tangem.blockchain.blockchains.solana + +import com.tangem.blockchain.blockchains.solana.solanaj.core.createAssociatedSolanaTokenAddress +import com.tangem.blockchain.blockchains.solana.solanaj.model.SolanaSplAccountInfo +import com.tangem.blockchain.blockchains.solana.solanaj.program.SolanaTokenProgramId +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.map +import com.tangem.blockchain.extensions.successOr +import com.tangem.blockchain.network.MultiNetworkProvider +import org.p2p.solanaj.core.PublicKey + +internal class SolanaTokenAccountInfoFinder( + private val multiNetworkProvider: MultiNetworkProvider, +) { + + suspend fun getTokenAccountInfoAndTokenProgramId( + account: PublicKey, + mint: PublicKey, + ): Result> { + val resultForTokenProgram = getTokenAccountInfoIfExist( + account = account, + mint = mint, + programId = SolanaTokenProgramId.TOKEN, + ) + + return when (resultForTokenProgram) { + is Result.Failure -> { + getTokenAccountInfoIfExist( + account = account, + mint = mint, + programId = SolanaTokenProgramId.TOKEN_2022, + ).map { it to SolanaTokenProgramId.TOKEN_2022 } + } + is Result.Success -> { + resultForTokenProgram.map { it to SolanaTokenProgramId.TOKEN } + } + } + } + + suspend fun getTokenAccountInfoIfExist( + account: PublicKey, + mint: PublicKey, + programId: SolanaTokenProgramId, + ): Result { + val associatedTokenAddress = createAssociatedSolanaTokenAddress( + account = account, + mint = mint, + tokenProgramId = programId, + ).getOrElse { + return Result.Failure(BlockchainSdkError.Solana.FailedToCreateAssociatedAccount) + } + + val tokenAccountInfo = multiNetworkProvider.performRequest { + getTokenAccountInfoIfExist(associatedTokenAddress) + }.successOr { return it } + + return Result.Success(tokenAccountInfo) + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTransactionBuilder.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTransactionBuilder.kt new file mode 100644 index 000000000..7664c9cde --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaTransactionBuilder.kt @@ -0,0 +1,167 @@ +package com.tangem.blockchain.blockchains.solana + +import com.tangem.blockchain.blockchains.solana.solanaj.core.SolanaTransaction +import com.tangem.blockchain.blockchains.solana.solanaj.core.createAssociatedSolanaTokenAddress +import com.tangem.blockchain.blockchains.solana.solanaj.program.SolanaTokenProgramId +import com.tangem.blockchain.blockchains.solana.solanaj.program.createSolanaTransferCheckedInstruction +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.extensions.successOr +import com.tangem.blockchain.network.MultiNetworkProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.p2p.solanaj.core.PublicKey +import org.p2p.solanaj.programs.AssociatedTokenProgram +import org.p2p.solanaj.programs.Program +import org.p2p.solanaj.programs.SystemProgram +import java.math.BigDecimal + +internal class SolanaTransactionBuilder( + private val account: PublicKey, + private val multiNetworkProvider: MultiNetworkProvider, + private val tokenProgramFinder: SolanaTokenAccountInfoFinder, +) { + + suspend fun buildUnsignedTransaction(destinationAddress: String, amount: Amount): Result { + return when (amount.type) { + is AmountType.Coin -> buildUnsignedCoinTransaction( + destinationAddress = destinationAddress, + amount = amount, + ) + is AmountType.Token -> buildUnsignedTokenTransaction( + destinationAddress = destinationAddress, + amount = amount, + token = amount.type.token, + ) + is AmountType.Reserve -> Result.Failure(BlockchainSdkError.UnsupportedOperation()) + } + } + + private suspend fun buildUnsignedCoinTransaction( + destinationAddress: String, + amount: Amount, + ): Result { + val recentBlockHash = multiNetworkProvider.performRequest { getRecentBlockhash() }.successOr { + return Result.Failure(it.error) + } + val destinationAccount = PublicKey(destinationAddress) + val lamports = SolanaValueConverter.toLamports(amount.value ?: BigDecimal.ZERO) + + val transaction = SolanaTransaction(account) + transaction.addInstruction(SystemProgram.transfer(account, destinationAccount, lamports)) + transaction.setRecentBlockHash(recentBlockHash) + + return Result.Success(transaction) + } + + private suspend fun buildUnsignedTokenTransaction( + destinationAddress: String, + amount: Amount, + token: Token, + ): Result { + val destinationAccount = PublicKey(destinationAddress) + val mint = PublicKey(token.contractAddress) + + val (tokenInfo, tokenProgramId) = tokenProgramFinder.getTokenAccountInfoAndTokenProgramId( + account = account, + mint = mint, + ).successOr { return it } + + val destinationAssociatedAccount = createAssociatedSolanaTokenAddress( + account = destinationAccount, + mint = mint, + tokenProgramId = tokenProgramId, + ).getOrElse { + return Result.Failure(BlockchainSdkError.Solana.FailedToCreateAssociatedAccount) + } + + if (tokenInfo.associatedPubK == destinationAssociatedAccount) { + return Result.Failure(BlockchainSdkError.Solana.SameSourceAndDestinationAddress) + } + + val transaction = SolanaTransaction(account).apply { + addInstructions( + tokenProgramId = tokenProgramId, + mint = mint, + destinationAssociatedAccount = destinationAssociatedAccount, + destinationAccount = destinationAccount, + sourceAssociatedAccount = tokenInfo.associatedPubK, + token = token, + amount = amount.value, + ).successOr { return Result.Failure(it.error) } + + val recentBlockHash = multiNetworkProvider.performRequest { + getRecentBlockhash() + }.successOr { return it } + setRecentBlockHash(recentBlockHash) + } + + return Result.Success(transaction) + } + + @Suppress("LongParameterList") + private suspend fun SolanaTransaction.addInstructions( + tokenProgramId: SolanaTokenProgramId, + mint: PublicKey, + destinationAssociatedAccount: PublicKey, + destinationAccount: PublicKey, + sourceAssociatedAccount: PublicKey, + token: Token, + amount: BigDecimal?, + ): SimpleResult { + val isDestinationAccountExists = isTokenAccountExist(destinationAssociatedAccount) + .successOr { return SimpleResult.Failure(it.error) } + + if (!isDestinationAccountExists) { + val associatedTokenInstruction = AssociatedTokenProgram.createAssociatedTokenAccountInstruction( + /* associatedProgramId = */ Program.Id.splAssociatedTokenAccount, + /* programId = */ tokenProgramId.value, + /* mint = */ mint, + /* associatedAccount = */ destinationAssociatedAccount, + /* owner = */ destinationAccount, + /* payer = */ account, + ) + addInstruction(associatedTokenInstruction) + } + + val sendInstruction = createSolanaTransferCheckedInstruction( + source = sourceAssociatedAccount, + destination = destinationAssociatedAccount, + amount = SolanaValueConverter.toLamports( + token = token, + value = amount ?: BigDecimal.ZERO, + ), + owner = account, + decimals = token.decimals.toByte(), + tokenMint = mint, + programId = tokenProgramId, + ) + addInstruction(sendInstruction) + + return SimpleResult.Success + } + + private suspend fun isTokenAccountExist(associatedAccount: PublicKey): Result { + return withContext(Dispatchers.IO) { + val infoResult = multiNetworkProvider.performRequest { + getTokenAccountInfoIfExist(associatedAccount) + } + + when { + infoResult is Result.Failure && infoResult.error is BlockchainSdkError.AccountNotFound -> { + Result.Success(data = false) + } + infoResult is Result.Failure -> { + Result.Failure(infoResult.error) + } + else -> { + Result.Success(data = true) + } + } + } + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaValueConverter.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaValueConverter.kt new file mode 100644 index 000000000..d2d2caf8d --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaValueConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.blockchain.blockchains.solana + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import java.math.BigDecimal +import java.math.RoundingMode + +internal object SolanaValueConverter { + + fun toSol(value: BigDecimal): BigDecimal = value.movePointLeft(Blockchain.Solana.decimals()).toSolanaDecimals() + + fun toSol(value: Long): BigDecimal = toSol(value.toBigDecimal()) + + fun toLamports(value: BigDecimal): Long = value.toLamports(Blockchain.Solana.decimals()) + + fun toLamports(token: Token, value: BigDecimal): Long = value.toLamports(token.decimals) + + private fun BigDecimal.toSolanaDecimals(): BigDecimal = setScale(Blockchain.Solana.decimals(), RoundingMode.HALF_UP) + + private fun BigDecimal.toLamports(decimals: Int): Long = movePointRight(decimals).toSolanaDecimals().toLong() +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaWalletManager.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaWalletManager.kt index a4c942c14..964556056 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaWalletManager.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/SolanaWalletManager.kt @@ -1,62 +1,64 @@ package com.tangem.blockchain.blockchains.solana import android.util.Log -import com.tangem.blockchain.blockchains.solana.solanaj.core.Transaction -import com.tangem.blockchain.blockchains.solana.solanaj.core.createAssociatedTokenAddress -import com.tangem.blockchain.blockchains.solana.solanaj.program.TokenProgramId -import com.tangem.blockchain.blockchains.solana.solanaj.program.createTransferCheckedInstruction -import com.tangem.blockchain.blockchains.solana.solanaj.rpc.RpcClient +import com.tangem.blockchain.blockchains.solana.solanaj.core.SolanaTransaction +import com.tangem.blockchain.blockchains.solana.solanaj.model.SolanaMainAccountInfo +import com.tangem.blockchain.blockchains.solana.solanaj.model.SolanaSplAccountInfo +import com.tangem.blockchain.blockchains.solana.solanaj.model.TransactionInfo +import com.tangem.blockchain.blockchains.solana.solanaj.rpc.SolanaRpcClient import com.tangem.blockchain.common.* -import com.tangem.blockchain.common.BlockchainSdkError.NPError -import com.tangem.blockchain.common.BlockchainSdkError.Solana import com.tangem.blockchain.common.BlockchainSdkError.UnsupportedOperation import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchain.extensions.Result -import com.tangem.blockchain.extensions.SimpleResult -import com.tangem.blockchain.extensions.filterWith -import com.tangem.blockchain.extensions.successOr +import com.tangem.blockchain.extensions.* import com.tangem.blockchain.network.MultiNetworkProvider -import com.tangem.common.CompletionResult -import com.tangem.common.extensions.guard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext import org.p2p.solanaj.core.PublicKey -import org.p2p.solanaj.programs.AssociatedTokenProgram import org.p2p.solanaj.programs.Program -import org.p2p.solanaj.programs.SystemProgram +import org.p2p.solanaj.rpc.Cluster import org.p2p.solanaj.rpc.types.config.Commitment import java.math.BigDecimal -import java.math.RoundingMode /** * Created by Anton Zhilenkov on 21/01/2022. */ // FIXME: Refactor with wallet-core: https://tangem.atlassian.net/browse/AND-5706 -@Suppress("LargeClass") -class SolanaWalletManager( +class SolanaWalletManager internal constructor( wallet: Wallet, - providers: List, + providers: List, ) : WalletManager(wallet), TransactionSender, RentProvider { - private val accountPubK: PublicKey = PublicKey(wallet.address) + private val account = PublicKey(wallet.address) private val networkServices = providers.map { SolanaNetworkService(it) } private val multiNetworkProvider: MultiNetworkProvider = MultiNetworkProvider(networkServices) + private val tokenAccountInfoFinder = SolanaTokenAccountInfoFinder(multiNetworkProvider) + private val transactionBuilder = SolanaTransactionBuilder(account, multiNetworkProvider, tokenAccountInfoFinder) + + private var accountSize: Long? = null override val currentHost: String get() = multiNetworkProvider.currentProvider.baseUrl private val feeRentHolder = mutableMapOf() - private val valueConverter = ValueConverter() - override suspend fun updateInternal() { val accountInfo = multiNetworkProvider.performRequest { - getMainAccountInfo(accountPubK) + getMainAccountInfo(account) }.successOr { - wallet.removeAllTokens() - throw it.error as BlockchainSdkError + return updateWithError(it.error) } - wallet.setCoinValue(valueConverter.toSol(accountInfo.balance)) + + updateInternal(accountInfo) + } + + private suspend fun updateInternal(accountInfo: SolanaMainAccountInfo) { + accountSize = accountInfo.value?.space ?: MIN_ACCOUNT_DATA_SIZE + wallet.setCoinValue(SolanaValueConverter.toSol(accountInfo.balance)) + updateRecentTransactions() addToRecentTransactions(accountInfo.txsInProgress) @@ -67,6 +69,13 @@ class SolanaWalletManager( } } + private fun updateWithError(error: BlockchainError) { + Log.e(this::class.java.simpleName, error.customMessage) + + wallet.removeAllTokens() + throw error + } + private suspend fun updateRecentTransactions() { val txSignatures = wallet.recentTransactions.mapNotNull { it.hash } val signatureStatuses = multiNetworkProvider.performRequest { @@ -98,8 +107,8 @@ class SolanaWalletManager( val newUnconfirmedTxData = newTxsInProgress.mapNotNull { if (it.instructions.isNotEmpty() && it.instructions[0].programId == Program.Id.system.toBase58()) { val info = it.instructions[0].parsed.info - val amount = Amount(valueConverter.toSol(info.lamports), wallet.blockchain) - val feeAmount = Amount(valueConverter.toSol(it.fee), wallet.blockchain) + val amount = Amount(SolanaValueConverter.toSol(info.lamports), wallet.blockchain) + val feeAmount = Amount(SolanaValueConverter.toSol(it.fee), wallet.blockchain) TransactionData( amount, Fee.Common(feeAmount), @@ -127,216 +136,33 @@ class SolanaWalletManager( val newAmount = amount.plus(accountCreationRent) super.createTransaction(newAmount, newFee, destination) } - is AmountType.Token -> { super.createTransaction(amount, fee, destination) } - AmountType.Reserve -> throw UnsupportedOperation() } } } override suspend fun send(transactionData: TransactionData, signer: TransactionSigner): SimpleResult { - return when (transactionData.amount.type) { - AmountType.Coin -> sendCoin(transactionData, signer) - is AmountType.Token -> sendToken( - transactionData.amount.type.token, - transactionData, - signer, - ) - - AmountType.Reserve -> SimpleResult.Failure(UnsupportedOperation()) - } - } - - private suspend fun sendCoin(transactionData: TransactionData, signer: TransactionSigner): SimpleResult { - val recentBlockHash = multiNetworkProvider.performRequest { getRecentBlockhash() }.successOr { - return SimpleResult.Failure(it.error) - } - val from = PublicKey(transactionData.sourceAddress) - val to = PublicKey(transactionData.destinationAddress) - val lamports = ValueConverter().toLamports(transactionData.amount.value ?: BigDecimal.ZERO) - val transaction = Transaction(accountPubK) - transaction.addInstruction(SystemProgram.transfer(from, to, lamports)) - transaction.setRecentBlockHash(recentBlockHash) - - val signResult = signer.sign(transaction.getDataForSign(), wallet.publicKey).successOr { - return SimpleResult.fromTangemSdkError(it.error) - } - - transaction.addSignedDataSignature(signResult) - val result = multiNetworkProvider.performRequest { - sendTransaction(transaction) - }.successOr { - return SimpleResult.Failure(it.error) - } - - feeRentHolder.clear() - transactionData.hash = result - wallet.addOutgoingTransaction(transactionData, false) - - return SimpleResult.Success - } - - private suspend fun sendToken( - token: Token, - transactionData: TransactionData, - signer: TransactionSigner, - ): SimpleResult { - val transaction = buildTokenTransaction(token, transactionData, signer) - .successOr { return SimpleResult.Failure(it.error) } + val transaction = transactionBuilder.buildUnsignedTransaction( + destinationAddress = transactionData.destinationAddress, + amount = transactionData.amount, + ).successOr { return it.toSimpleResult() } - return sendTokenTransaction(transaction, transactionData) + return sendTransaction(transaction, transactionData, signer) } - private suspend fun buildTokenTransaction( - token: Token, + private suspend fun sendTransaction( + transaction: SolanaTransaction, transactionData: TransactionData, signer: TransactionSigner, - ): Result { - val sourceAccount = PublicKey(transactionData.sourceAddress) - val destinationAccount = PublicKey(transactionData.destinationAddress) - val mint = transactionData.contractAddress - .guard { return Result.Failure(NPError("contractAddress")) } - .let(::PublicKey) - - val (sourceAssociatedAccount, tokenProgramId) = getAssociatedAccountAndTokenProgramId( - account = sourceAccount, - mint = mint, - ).successOr { return it } - - val destinationAssociatedAccount = createAssociatedTokenAddress( - account = destinationAccount, - mint = mint, - tokenProgramId = tokenProgramId, - ).getOrElse { - return Result.Failure(Solana.FailedToCreateAssociatedAccount) - } - - if (sourceAssociatedAccount == destinationAssociatedAccount) { - return Result.Failure(Solana.SameSourceAndDestinationAddress) - } - - val transaction = Transaction(accountPubK).apply { - addInstructions( - tokenProgramId = tokenProgramId, - mint = mint, - destinationAssociatedAccount = destinationAssociatedAccount, - destinationAccount = destinationAccount, - sourceAssociatedAccount = sourceAssociatedAccount, - token = token, - transactionData = transactionData, - ).successOr { return Result.Failure(it.error) } - - val recentBlockHash = multiNetworkProvider.performRequest { - getRecentBlockhash() - }.successOr { - return it - } - setRecentBlockHash(recentBlockHash) - - val signResult = signer.sign(getDataForSign(), wallet.publicKey).successOr { - return Result.fromTangemSdkError(it.error) - } - addSignedDataSignature(signResult) - } - - return Result.Success(transaction) - } - - @Suppress("LongParameterList") - private suspend fun Transaction.addInstructions( - tokenProgramId: TokenProgramId, - mint: PublicKey, - destinationAssociatedAccount: PublicKey, - destinationAccount: PublicKey, - sourceAssociatedAccount: PublicKey, - token: Token, - transactionData: TransactionData, ): SimpleResult { - val isDestinationAccountExists = multiNetworkProvider.performRequest { - isTokenAccountExist(destinationAssociatedAccount) - }.successOr { return SimpleResult.Failure(it.error) } - - if (!isDestinationAccountExists) { - val associatedTokenInstruction = AssociatedTokenProgram.createAssociatedTokenAccountInstruction( - /* associatedProgramId = */ Program.Id.splAssociatedTokenAccount, - /* programId = */ tokenProgramId.value, - /* mint = */ mint, - /* associatedAccount = */ destinationAssociatedAccount, - /* owner = */ destinationAccount, - /* payer = */ accountPubK, - ) - addInstruction(associatedTokenInstruction) - } - - val sendInstruction = createTransferCheckedInstruction( - source = sourceAssociatedAccount, - destination = destinationAssociatedAccount, - amount = valueConverter.toLamports( - token = token, - value = transactionData.amount.value ?: BigDecimal.ZERO, - ), - owner = accountPubK, - decimals = token.decimals.toByte(), - tokenMint = mint, - programId = tokenProgramId, - ) - addInstruction(sendInstruction) - - return SimpleResult.Success - } - - private suspend fun getAssociatedAccountAndTokenProgramId( - account: PublicKey, - mint: PublicKey, - ): Result> { - val resultForTokenProgram = tryGetAssociatedTokenAddressAndTokenProgramId( - account = account, - mint = mint, - programId = TokenProgramId.TOKEN, - ) - - return when (resultForTokenProgram) { - is Result.Failure -> tryGetAssociatedTokenAddressAndTokenProgramId( - account = account, - mint = mint, - programId = TokenProgramId.TOKEN_2022, - ) - - is Result.Success -> resultForTokenProgram - } - } - - private suspend fun tryGetAssociatedTokenAddressAndTokenProgramId( - account: PublicKey, - mint: PublicKey, - programId: TokenProgramId, - ): Result> { - val associatedTokenAddress = createAssociatedTokenAddress( - account = account, - mint = mint, - tokenProgramId = programId, - ).getOrElse { - return Result.Failure(Solana.FailedToCreateAssociatedAccount) - } - - val isTokenAccountExist = multiNetworkProvider.performRequest { - isTokenAccountExist(associatedTokenAddress) - }.successOr { return it } - - return if (isTokenAccountExist) { - Result.Success(data = associatedTokenAddress to programId) - } else { - Result.Failure(Solana.FailedToCreateAssociatedAccount) + val signResult = signer.sign(transaction.getSerializedMessage(), wallet.publicKey).successOr { + return SimpleResult.fromTangemSdkError(it.error) } - } + transaction.addSignedDataSignature(signResult) - private suspend fun sendTokenTransaction( - transaction: Transaction, - transactionData: TransactionData, - ): SimpleResult { val sendResult = multiNetworkProvider.performRequest { sendTransaction(transaction) }.successOr { @@ -357,13 +183,10 @@ class SolanaWalletManager( */ override suspend fun getFee(amount: Amount, destination: String): Result { feeRentHolder.clear() - val fee = getNetworkFee().successOr { return it } - val accountCreationRent = - getAccountCreationRent(amount, destination).successOr { return it }.let { - valueConverter.toSol(it) - } + val (networkFee, accountCreationRent) = getNetworkFeeAndAccountCreationRent(amount, destination) + .successOr { return it } - var feeAmount = Fee.Common(Amount(valueConverter.toSol(fee), wallet.blockchain)) + var feeAmount = Fee.Common(Amount(networkFee, wallet.blockchain)) if (accountCreationRent > BigDecimal.ZERO) { feeAmount = feeAmount.copy(amount = feeAmount.amount + accountCreationRent) feeRentHolder[feeAmount] = accountCreationRent @@ -372,140 +195,152 @@ class SolanaWalletManager( return Result.Success(TransactionFee.Single(feeAmount)) } - override suspend fun estimateFee(amount: Amount, destination: String): Result { - val feeRawValue = getNetworkFee().successOr { return it } - val feeAmount = Fee.Common(Amount(valueConverter.toSol(feeRawValue), wallet.blockchain)) - return Result.Success(TransactionFee.Single(feeAmount)) + private suspend fun getNetworkFeeAndAccountCreationRent( + amount: Amount, + destination: String, + ): Result> { + val results = withContext(Dispatchers.IO) { + awaitAll( + async { getNetworkFee(amount, destination) }, + async { getAccountCreationRent(amount, destination) }, + ) + } + val networkFee = results[0].successOr { return it } + val accountCreationRent = results[1].successOr { return it } + + return Result.Success(data = networkFee to accountCreationRent) } - private suspend fun getNetworkFee(): Result { - return when (val result = multiNetworkProvider.performRequest { getFees() }) { - is Result.Success -> { - val feePerSignature = result.data.value.feeCalculator.lamportsPerSignature - Result.Success(feePerSignature.toBigDecimal()) - } + private suspend fun getNetworkFee(amount: Amount, destination: String): Result { + val transaction = transactionBuilder.buildUnsignedTransaction( + destinationAddress = destination, + amount = amount, + ).successOr { return it } + val result = multiNetworkProvider.performRequest { + getFeeForMessage(transaction) + }.successOr { return it } - is Result.Failure -> result - } + return Result.Success(result.value.let(SolanaValueConverter::toSol)) } private suspend fun getAccountCreationRent(amount: Amount, destination: String): Result { - val amountValue = amount.value.guard { - return Result.Failure(NPError("amountValue")) - } - val destinationPubKey = PublicKey(destination) - - val accountCreationFee = when (amount.type) { - AmountType.Coin -> { - val isExist = - multiNetworkProvider.performRequest { - isAccountExist(destinationPubKey) - }.successOr { return it } - if (isExist) return Result.Success(BigDecimal.ZERO) - val minRentExempt = - multiNetworkProvider.performRequest { - minimalBalanceForRentExemption() - }.successOr { return it } - - if (valueConverter.toLamports(amountValue).toBigDecimal() >= minRentExempt) { - BigDecimal.ZERO - } else { - multiNetworkProvider.currentProvider.mainAccountCreationFee() - } - } + val destinationAccount = PublicKey(destination) + return when (amount.type) { + is AmountType.Coin -> { + getCoinAccountCreationRent(amount, destinationAccount) + } is AmountType.Token -> { - val isExist = isTokenAccountExist( - account = destinationPubKey, - mint = PublicKey(amount.type.token.contractAddress), - ).successOr { return it } - - if (isExist) { - BigDecimal.ZERO - } else { - multiNetworkProvider.performRequest { - tokenAccountCreationFee() - }.successOr { return it } - } + val mint = PublicKey(amount.type.token.contractAddress) + getTokenAccountCreationRent(mint, destinationAccount) } + is AmountType.Reserve -> Result.Failure(UnsupportedOperation()) + } + } - AmountType.Reserve -> return Result.Failure(UnsupportedOperation()) + private suspend fun getCoinAccountCreationRent(amount: Amount, destinationAccount: PublicKey): Result { + val amountValue = amount.value + ?: return Result.Failure(BlockchainSdkError.NPError("amountValue")) + + multiNetworkProvider.performRequest { + getAccountInfoIfExist(destinationAccount) + }.successOr { failure -> + return if (failure.error is BlockchainSdkError.AccountNotFound) { + getCoinAccountCreationRent(amountValue) + } else { + failure + } } - return Result.Success(accountCreationFee) + return Result.Success(BigDecimal.ZERO) } - private suspend fun isTokenAccountExist(account: PublicKey, mint: PublicKey): Result { - val isTokenAccountExistInTokenProgram = isTokenAccountExist(account, mint, TokenProgramId.TOKEN) + private suspend fun getCoinAccountCreationRent(balance: BigDecimal): Result { + val balanceForRentExemption = getMinimalBalanceForRentExemptionInSol(MIN_ACCOUNT_DATA_SIZE) .successOr { return it } - return if (isTokenAccountExistInTokenProgram) { - Result.Success(data = true) + val rent = if (balance >= balanceForRentExemption) { + BigDecimal.ZERO } else { - isTokenAccountExist(account, mint, TokenProgramId.TOKEN_2022) + balanceForRentExemption } + + return Result.Success(rent) } - private suspend fun isTokenAccountExist( - account: PublicKey, + private suspend fun getTokenAccountCreationRent( mint: PublicKey, - programId: TokenProgramId, - ): Result { - val associatedTokenAddress = createAssociatedTokenAddress( + destinationAccount: PublicKey, + ): Result { + val (sourceTokenAccountInfo, programId) = tokenAccountInfoFinder.getTokenAccountInfoAndTokenProgramId( account = account, mint = mint, - tokenProgramId = programId, - ).getOrElse { - return Result.Failure(Solana.FailedToCreateAssociatedAccount) + ).successOr { return it } + + tokenAccountInfoFinder.getTokenAccountInfoIfExist( + account = destinationAccount, + mint = mint, + programId = programId, + ).successOr { failure -> + return if (failure.error is BlockchainSdkError.AccountNotFound) { + getTokenAccountCreationRent(sourceTokenAccountInfo) + } else { + failure + } } - return multiNetworkProvider.performRequest { - isTokenAccountExist(associatedTokenAddress) + return Result.Success(BigDecimal.ZERO) + } + + private suspend fun getTokenAccountCreationRent(sourceTokenAccountInfo: SolanaSplAccountInfo): Result { + val sourceTokenAccountSize = requireNotNull(sourceTokenAccountInfo.value.data?.space) { + "Source token account data must not be null" } + val rent = getMinimalBalanceForRentExemptionInSol(sourceTokenAccountSize.toLong()) + .successOr { return it } + + return Result.Success(rent) } override suspend fun minimalBalanceForRentExemption(): Result { - return when ( - val result = multiNetworkProvider.performRequest { - minimalBalanceForRentExemption() - } - ) { - is Result.Success -> Result.Success(valueConverter.toSol(result.data)) - is Result.Failure -> result + val size = requireNotNull(accountSize) { + "Account size must be initialized. Call update() first." } - } - override suspend fun rentAmount(): BigDecimal { - return valueConverter.toSol(multiNetworkProvider.currentProvider.accountRentFeeByEpoch()) + return getMinimalBalanceForRentExemptionInSol(size) } -} - -interface SolanaValueConverter { - fun toSol(value: BigDecimal): BigDecimal - fun toSol(value: Long): BigDecimal - fun toLamports(value: BigDecimal): Long - fun toLamports(token: Token, value: BigDecimal): Long -} -class ValueConverter : SolanaValueConverter { - override fun toSol(value: BigDecimal): BigDecimal = value.toSOL() - override fun toSol(value: Long): BigDecimal = value.toBigDecimal().toSOL() - override fun toLamports(value: BigDecimal): Long = value.toLamports(Blockchain.Solana.decimals()) + // FIXME: The rent calculation is based on hardcoded values that may be changed in the future + override suspend fun rentAmount(): BigDecimal { + val size = requireNotNull(accountSize) { + "Account size must be initialized. Call update() first." + } + val accountSizeWithMetadata = (size + ACCOUNT_METADATA_SIZE).toBigDecimal() + val rentPerEpoch = determineRentPerEpoch(multiNetworkProvider.currentProvider).toBigDecimal() - override fun toLamports(token: Token, value: BigDecimal): Long = value.toLamports(token.decimals) -} + return accountSizeWithMetadata + .multiply(rentPerEpoch) + .let(SolanaValueConverter::toSol) + } -private fun Long.toSOL(): BigDecimal = this.toBigDecimal().toSOL() -private fun BigDecimal.toSOL(): BigDecimal = movePointLeft(Blockchain.Solana.decimals()).toSolanaDecimals() + private suspend fun getMinimalBalanceForRentExemptionInSol(accountSize: Long): Result { + return multiNetworkProvider.performRequest { + minimalBalanceForRentExemption(accountSize) + } + .map(SolanaValueConverter::toSol) + } -private fun BigDecimal.toLamports(decimals: Int): Long = movePointRight(decimals).toSolanaDecimals().toLong() + private fun determineRentPerEpoch(provider: SolanaNetworkService): Double = when (provider.endpoint) { + Cluster.TESTNET.endpoint -> RENT_PER_EPOCH_IN_LAMPORTS + Cluster.DEVNET.endpoint -> RENT_PER_EPOCH_IN_LAMPORTS_DEV_NET + else -> RENT_PER_EPOCH_IN_LAMPORTS + } -private fun BigDecimal.toSolanaDecimals(): BigDecimal = - this.setScale(Blockchain.Solana.decimals(), RoundingMode.HALF_UP) + private companion object { + const val MIN_ACCOUNT_DATA_SIZE = 0L -private inline fun CompletionResult.successOr(failureClause: (CompletionResult.Failure) -> Nothing): T { - return when (this) { - is CompletionResult.Success -> this.data - is CompletionResult.Failure -> failureClause(this) + const val ACCOUNT_METADATA_SIZE = 128L + const val RENT_PER_EPOCH_IN_LAMPORTS = 19.055441478439427 + const val RENT_PER_EPOCH_IN_LAMPORTS_DEV_NET = 0.359375 } } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/PublicKeyExt.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/PublicKeyExt.kt index a1241f91f..1ea8adb8b 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/PublicKeyExt.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/PublicKeyExt.kt @@ -1,16 +1,16 @@ package com.tangem.blockchain.blockchains.solana.solanaj.core -import com.tangem.blockchain.blockchains.solana.solanaj.program.TokenProgramId +import com.tangem.blockchain.blockchains.solana.solanaj.program.SolanaTokenProgramId import org.p2p.solanaj.core.PublicKey import org.p2p.solanaj.programs.Program /** * Same as [org.p2p.solanaj.core.PublicKey.associatedTokenAddress] but with [tokenProgramId] * */ -internal fun createAssociatedTokenAddress( +internal fun createAssociatedSolanaTokenAddress( account: PublicKey, mint: PublicKey, - tokenProgramId: TokenProgramId, + tokenProgramId: SolanaTokenProgramId, ): Result = runCatching { val seeds = buildList { add(account.toByteArray()) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Message.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaMessage.kt similarity index 94% rename from blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Message.kt rename to blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaMessage.kt index 1763a134d..1c785cdd9 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Message.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaMessage.kt @@ -7,7 +7,7 @@ import org.p2p.solanaj.core.PublicKey /** * Created by Anton Zhilenkov on 26/01/2022. */ -class Message( +internal class SolanaMessage( private val feePayerPublicKey: PublicKey, ) : Message() { diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Transaction.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaTransaction.kt similarity index 84% rename from blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Transaction.kt rename to blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaTransaction.kt index a4e3c1439..809978960 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/Transaction.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/core/SolanaTransaction.kt @@ -8,9 +8,9 @@ import org.p2p.solanaj.core.Transaction /** * Created by Anton Zhilenkov on 26/01/2022. */ -class Transaction( - private val feePayerPublicKey: PublicKey, -) : Transaction(Message(feePayerPublicKey)) { +internal class SolanaTransaction( + feePayerPublicKey: PublicKey, +) : Transaction(SolanaMessage(feePayerPublicKey)) { @Deprecated("Instead, use getDataForSign and then addSignedDataSignature before submitting the transaction.") override fun sign(signer: Account?) { @@ -22,7 +22,7 @@ class Transaction( throw UnsupportedOperationException() } - fun getDataForSign(): ByteArray { + fun getSerializedMessage(): ByteArray { serializedMessage = message.serialize() return serializedMessage } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/FeeInfo.java b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/FeeInfo.java new file mode 100644 index 000000000..004b9dd4b --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/FeeInfo.java @@ -0,0 +1,23 @@ +package com.tangem.blockchain.blockchains.solana.solanaj.model; + +import com.squareup.moshi.Json; + +import org.p2p.solanaj.rpc.types.RpcResultObject; + +public class FeeInfo extends RpcResultObject { + + @Json(name = "value") + private long value; + + public FeeInfo() { + } + + public long getValue() { + return value; + } + + @Override + public String toString() { + return "FeeInfo(value=" + value + ")"; + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaAccountInfo.java b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaAccountInfo.java new file mode 100644 index 000000000..2535d1986 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaAccountInfo.java @@ -0,0 +1,42 @@ +package com.tangem.blockchain.blockchains.solana.solanaj.model; + +import com.squareup.moshi.Json; + +import org.p2p.solanaj.rpc.types.AccountInfo; + +import java.util.AbstractMap; + +/** + * Same as [org.p2p.solanaj.rpc.types.AccountInfo] but with space field + */ +public class SolanaAccountInfo { + + @Json( + name = "value" + ) + private Value value; + + public SolanaAccountInfo() { + } + + public Value getValue() { + return this.value; + } + + public static class Value extends AccountInfo.Value { + + @Json( + name = "space" + ) + private final long space; + + public Value(AbstractMap am) { + super(am); + this.space = (long) am.get("space"); + } + + public long getSpace() { + return this.space; + } + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaMainAccountInfo.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaMainAccountInfo.kt new file mode 100644 index 000000000..f3f0b3552 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/SolanaMainAccountInfo.kt @@ -0,0 +1,27 @@ +package com.tangem.blockchain.blockchains.solana.solanaj.model + +import org.p2p.solanaj.core.PublicKey +import org.p2p.solanaj.rpc.types.TokenAccountInfo +import org.p2p.solanaj.rpc.types.TokenResultObjects +import java.math.BigDecimal + +internal data class SolanaMainAccountInfo( + val value: SolanaAccountInfo.Value?, + val tokensByMint: Map, + val txsInProgress: List, +) { + val balance: Long + get() = value?.lamports ?: 0 +} + +internal data class SolanaSplAccountInfo( + val value: TokenResultObjects.Value, + val associatedPubK: PublicKey, +) + +internal data class SolanaTokenAccountInfo( + val value: TokenAccountInfo.Value, + val address: String, + val mint: String, + val uiAmount: BigDecimal, // in SOL +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/TransactionInfo.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/TransactionInfo.kt new file mode 100644 index 000000000..1a08b0b6f --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/model/TransactionInfo.kt @@ -0,0 +1,9 @@ +package com.tangem.blockchain.blockchains.solana.solanaj.model + +import org.p2p.solanaj.rpc.types.TransactionResult + +internal data class TransactionInfo( + val signature: String, + val fee: Long, // in lamports + val instructions: List, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenProgramId.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/SolanaTokenProgramId.kt similarity index 80% rename from blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenProgramId.kt rename to blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/SolanaTokenProgramId.kt index dbcc5a4c3..e7e110cdb 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenProgramId.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/SolanaTokenProgramId.kt @@ -3,7 +3,7 @@ package com.tangem.blockchain.blockchains.solana.solanaj.program import org.p2p.solanaj.core.PublicKey import org.p2p.solanaj.programs.Program -internal enum class TokenProgramId(val value: PublicKey) { +internal enum class SolanaTokenProgramId(val value: PublicKey) { TOKEN(value = Program.Id.token), TOKEN_2022(value = PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")), } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenInstructions.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenInstructions.kt index 369436baa..836cab4f0 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenInstructions.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/program/TokenInstructions.kt @@ -11,14 +11,14 @@ import java.nio.ByteOrder * Same as [org.p2p.solanaj.programs.TokenProgram.transferChecked] but with [programId] * */ @Suppress("LongParameterList") -internal fun createTransferCheckedInstruction( +internal fun createSolanaTransferCheckedInstruction( source: PublicKey?, destination: PublicKey?, amount: Long, decimals: Byte, owner: PublicKey?, tokenMint: PublicKey?, - programId: TokenProgramId, + programId: SolanaTokenProgramId, ): TransactionInstruction { val keys = buildList { add(AccountMeta(source, false, true)) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcApi.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcApi.kt similarity index 50% rename from blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcApi.kt rename to blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcApi.kt index 847a56c3c..b5d3f015b 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcApi.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcApi.kt @@ -1,18 +1,24 @@ package com.tangem.blockchain.blockchains.solana.solanaj.rpc import android.util.Base64 +import com.tangem.blockchain.blockchains.solana.solanaj.core.SolanaTransaction +import com.tangem.blockchain.blockchains.solana.solanaj.model.FeeInfo +import com.tangem.blockchain.blockchains.solana.solanaj.model.SolanaAccountInfo import org.p2p.solanaj.core.Account +import org.p2p.solanaj.core.MapUtils +import org.p2p.solanaj.core.PublicKey import org.p2p.solanaj.core.Transaction import org.p2p.solanaj.rpc.RpcApi import org.p2p.solanaj.rpc.RpcClient import org.p2p.solanaj.rpc.RpcException +import org.p2p.solanaj.rpc.types.config.Commitment import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig import org.p2p.solanaj.ws.listeners.NotificationEventListener /** * Created by Anton Zhilenkov on 26/01/2022. */ -class RpcApi(rpcClient: RpcClient) : RpcApi(rpcClient) { +internal class SolanaRpcApi(rpcClient: RpcClient) : RpcApi(rpcClient) { @Deprecated("Use signAndSendTransaction instead") @Throws(UnsupportedOperationException::class) @@ -43,11 +49,46 @@ class RpcApi(rpcClient: RpcClient) : RpcApi(rpcClient) { } @Throws(RpcException::class) - fun sendSignedTransaction(transaction: Transaction): String { + fun sendSignedTransaction(transaction: SolanaTransaction): String { val serializedTransaction = transaction.serialize() val base64Trx = Base64.encodeToString(serializedTransaction, Base64.NO_WRAP) val params = mutableListOf(base64Trx, RpcSendTransactionConfig()) return client.call("sendTransaction", params, String::class.java) } + + fun getFeeForMessage(transaction: SolanaTransaction, commitment: Commitment): FeeInfo { + val message = Base64.encodeToString(transaction.getSerializedMessage(), Base64.NO_WRAP) + val params = buildList { + add(message) + add(mapOf("commitment" to commitment.value)) + } + + return client.call("getFeeForMessage", params, FeeInfo::class.java) + } + + /** + * Same as [RpcApi.getAccountInfo] but returns improved [SolanaAccountInfo] + * instead of [org.p2p.solanaj.rpc.types.AccountInfo] + * */ + fun getAccountInfoNew(account: PublicKey, additionalParams: Map): SolanaAccountInfo { + val params = buildList { + add(account.toString()) + + val parameterMap = buildMap { + this["encoding"] = MapUtils.getOrDefault(additionalParams, "encoding", "base64") + if (additionalParams.containsKey("commitment")) { + val commitment = additionalParams["commitment"] as Commitment + this["commitment"] = commitment.value + } + + if (additionalParams.containsKey("dataSlice")) { + this["dataSlice"] = additionalParams["dataSlice"] + } + } + + add(parameterMap) + } + return client.call("getAccountInfo", params, SolanaAccountInfo::class.java) + } } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcClient.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcClient.kt similarity index 63% rename from blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcClient.kt rename to blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcClient.kt index fc6574f16..7c2ccd669 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/RpcClient.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/solanaj/rpc/SolanaRpcClient.kt @@ -6,12 +6,11 @@ import org.p2p.solanaj.rpc.RpcClient /** * Created by Anton Zhilenkov on 26/01/2022. */ -class RpcClient( +internal class SolanaRpcClient( val host: String, httpInterceptors: List? = null, ) : RpcClient(host, httpInterceptors) { - override fun createRpcApi(): RpcApi = RpcApi(this) - - override fun getApi(): RpcApi = super.getApi() as RpcApi + override fun createRpcApi(): SolanaRpcApi = SolanaRpcApi(this) + override fun getApi(): SolanaRpcApi = super.getApi() as SolanaRpcApi } diff --git a/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt b/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt index 1e09f978a..cb0d7ad46 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt @@ -43,6 +43,13 @@ inline fun Result.successOr(failureClause: (Result.Failure) -> T): T { } } +inline fun Result.map(block: (A) -> B): Result { + return when (this) { + is Result.Success -> Result.Success(block(this.data)) + is Result.Failure -> this + } +} + fun Result.Failure.toSimpleFailure(): SimpleResult.Failure { return SimpleResult.Failure(error) } From 48fa2c47fcea670f70fd08ac8ff13525c025cd9f Mon Sep 17 00:00:00 2001 From: Anton Tkachev Date: Tue, 16 Jan 2024 11:13:26 +0300 Subject: [PATCH 2/2] AND-5746 Moved extensions for 'Result' model to other file --- .../blockchains/solana/ResultExt.kt | 19 -------------- .../blockchain/extensions/Coroutines.kt | 25 +++++++++++++++---- 2 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt deleted file mode 100644 index a6b029da8..000000000 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/solana/ResultExt.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.blockchain.blockchains.solana - -import com.tangem.blockchain.extensions.Result -import com.tangem.blockchain.extensions.SimpleResult -import com.tangem.common.CompletionResult - -internal fun Result.toSimpleResult(): SimpleResult { - return when (this) { - is Result.Success -> SimpleResult.Success - is Result.Failure -> SimpleResult.Failure(this.error) - } -} - -internal inline fun CompletionResult.successOr(failureClause: (CompletionResult.Failure) -> Nothing): T { - return when (this) { - is CompletionResult.Success -> this.data - is CompletionResult.Failure -> failureClause(this) - } -} diff --git a/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt b/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt index cb0d7ad46..04a883edf 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/extensions/Coroutines.kt @@ -2,11 +2,12 @@ package com.tangem.blockchain.extensions import com.tangem.blockchain.common.BlockchainError import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError import kotlinx.coroutines.delay import java.io.IOException -suspend fun retryIO( +internal suspend fun retryIO( times: Int = 3, initialDelay: Long = 100, maxDelay: Long = 1000, @@ -36,24 +37,31 @@ sealed class Result { } } -inline fun Result.successOr(failureClause: (Result.Failure) -> T): T { +internal inline fun Result.successOr(failureClause: (Result.Failure) -> T): T { return when (this) { is Result.Success -> this.data is Result.Failure -> failureClause(this) } } -inline fun Result.map(block: (A) -> B): Result { +internal inline fun Result.map(block: (A) -> B): Result { return when (this) { is Result.Success -> Result.Success(block(this.data)) is Result.Failure -> this } } -fun Result.Failure.toSimpleFailure(): SimpleResult.Failure { +internal fun Result.Failure.toSimpleFailure(): SimpleResult.Failure { return SimpleResult.Failure(error) } +internal fun Result.toSimpleResult(): SimpleResult { + return when (this) { + is Result.Success -> SimpleResult.Success + is Result.Failure -> SimpleResult.Failure(this.error) + } +} + sealed class SimpleResult { object Success : SimpleResult() data class Failure(val error: BlockchainError) : SimpleResult() @@ -65,9 +73,16 @@ sealed class SimpleResult { } } -inline fun SimpleResult.successOr(failureClause: (SimpleResult.Failure) -> Nothing): SimpleResult.Success { +internal inline fun SimpleResult.successOr(failureClause: (SimpleResult.Failure) -> Nothing): SimpleResult.Success { return when (this) { is SimpleResult.Success -> this is SimpleResult.Failure -> failureClause(this) } } + +internal inline fun CompletionResult.successOr(failureClause: (CompletionResult.Failure) -> Nothing): T { + return when (this) { + is CompletionResult.Success -> this.data + is CompletionResult.Failure -> failureClause(this) + } +}