Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-11 14:42:09 +05:00
parent ae33c00aae
commit 85cff6b0d3
10 changed files with 169 additions and 22 deletions

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.blockaid.models.request.DomainScanReques
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
import com.tangem.datasource.api.common.blockaid.models.response.SolanaTransactionResponse
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
import retrofit2.http.Body
import retrofit2.http.POST
@ -17,5 +18,5 @@ interface BlockAidApi {
suspend fun scanJsonRpc(@Body request: EvmTransactionScanRequest): TransactionScanResponse
@POST("solana/message/scan")
suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): TransactionScanResponse
suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): SolanaTransactionResponse
}

View file

@ -7,7 +7,7 @@ import com.tangem.datasource.api.common.blockaid.models.response.TransactionMeta
@JsonClass(generateAdapter = true)
data class SolanaTransactionScanRequest(
@Json(name = "encoding") val encoding: String = "base64",
@Json(name = "chain") val chain: String,
@Json(name = "blockchain") val blockchain: String,
@Json(name = "method") val method: String,
@Json(name = "options") val options: List<String> = listOf("simulation", "validation"),
@Json(name = "metadata") val metadata: TransactionMetadata,

View file

@ -0,0 +1,58 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SolanaTransactionResponse(
@Json(name = "result") val result: SolanaTransactionResult,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionResult(
@Json(name = "validation") val validation: SolanaTransactionValidation,
@Json(name = "simulation") val simulation: SolanaTransactionSimulation? = null,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionValidation(
@Json(name = "result_type") val resultType: String,
@Json(name = "description") val description: String?,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionSimulation(
@Json(name = "account_summary") val accountSummary: SolanaTransactionAccountSummary,
@Json(name = "error") val error: String? = null,
@Json(name = "error_details") val errorDetails: String? = null,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionAccountSummary(
@Json(name = "account_assets_diff")
val accountAssetsDiff: List<SolanaTransactionAssetDiff>,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionAssetDiff(
@Json(name = "asset_type") val assetType: String,
@Json(name = "asset") val asset: SolanaTransactionAsset,
@Json(name = "in") val inTransfer: SolanaTransferDetail? = null,
@Json(name = "out") val outTransfer: SolanaTransferDetail? = null,
)
@JsonClass(generateAdapter = true)
data class SolanaTransactionAsset(
@Json(name = "address") val address: String? = null,
@Json(name = "symbol") val symbol: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "decimals") val decimals: Int? = null,
@Json(name = "type") val type: String? = null,
@Json(name = "logo") val logoUrl: String? = null,
)
@JsonClass(generateAdapter = true)
data class SolanaTransferDetail(
@Json(name = "value") val amount: String? = null,
@Json(name = "summary") val summary: String? = null,
)

View file

@ -17,6 +17,9 @@ private const val SUCCESS_STATUS = "Success"
private const val DOMAIN_CHECKED_STATUS = "hit"
private const val VALIDATION_SAFE_STATUS = "Benign"
private const val VALIDATION_WARNING_STATUS = "Warning"
private const val VALIDATION_MALICIOUS_STATUS = "Malicious"
private const val SOL_ASSET_SYMBOL = "SOL"
internal object BlockAidMapper {
@ -28,6 +31,26 @@ internal object BlockAidMapper {
}
}
fun mapToDomain(from: SolanaTransactionResponse): CheckTransactionResult {
val validation = when (from.result.validation.resultType) {
VALIDATION_SAFE_STATUS -> ValidationResult.SAFE
VALIDATION_WARNING_STATUS -> ValidationResult.WARNING
VALIDATION_MALICIOUS_STATUS -> ValidationResult.UNSAFE
else -> ValidationResult.FAILED_TO_VALIDATE
}
val simulationResponse = from.result.simulation
val simulation = if (simulationResponse == null) {
SimulationResult.FailedToSimulate
} else {
mapToSolanaAssetsDiffs(simulationResponse.accountSummary.accountAssetsDiff)
}
return CheckTransactionResult(
validation = validation,
description = from.result.validation.description,
simulation = simulation,
)
}
fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult {
return CheckTransactionResult(
validation = when {
@ -68,7 +91,7 @@ internal object BlockAidMapper {
fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest {
return SolanaTransactionScanRequest(
chain = from.chain.lowercase(),
blockchain = from.chain.lowercase(),
accountAddress = from.accountAddress,
metadata = TransactionMetadata(from.domainUrl),
method = from.method,
@ -89,6 +112,49 @@ internal object BlockAidMapper {
}
}
private fun mapToSolanaAssetsDiffs(assetsDiffs: List<SolanaTransactionAssetDiff>): SimulationResult {
val sendInfo = assetsDiffs.mapNotNull { assetDiff ->
val outTransfer = assetDiff.outTransfer ?: return@mapNotNull null
val amount = outTransfer.amount?.toBigDecimalOrNull() ?: return@mapNotNull null
AmountInfo.FungibleTokens(
amount = amount,
token = TokenInfo(
chainId = null,
logoUrl = assetDiff.asset.logoUrl,
symbol = assetDiff.asset.assetSymbol(),
decimals = assetDiff.asset.decimals ?: 0,
),
)
}
val receiveInfo = assetsDiffs.mapNotNull { assetDiff ->
val inTransfer = assetDiff.inTransfer ?: return@mapNotNull null
val amount = inTransfer.amount?.toBigDecimalOrNull() ?: return@mapNotNull null
AmountInfo.FungibleTokens(
amount = amount,
token = TokenInfo(
chainId = null,
logoUrl = assetDiff.asset.logoUrl,
symbol = assetDiff.asset.assetSymbol(),
decimals = assetDiff.asset.decimals ?: 0,
),
)
}
return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) {
SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo))
} else {
SimulationResult.Success(SimulationData.NoWalletChangesDetected)
}
}
private fun SolanaTransactionAsset.assetSymbol(): String {
return if (type?.lowercase().equals(SOL_ASSET_SYMBOL, ignoreCase = true)) {
symbol ?: SOL_ASSET_SYMBOL
} else {
symbol.orEmpty()
}
}
private fun mapApproveTransaction(exposures: List<Exposure>?): SimulationResult {
val amounts = exposures?.flatMap { exposure ->
val tokenInfo = TokenInfo(

View file

@ -24,16 +24,21 @@ internal class DefaultBlockAidRepository(
}
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
val response = withContext(dispatchers.io) {
when (data.params) {
is TransactionParams.Evm -> {
api.scanJsonRpc(mapper.mapToEvmRequest(data))
}
is TransactionParams.Solana -> {
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
}
}
return when (data.params) {
is TransactionParams.Evm -> scanEvmTransaction(data = data)
is TransactionParams.Solana -> scanSolanaTransaction(data = data)
}
return mapper.mapToDomain(response)
}
private suspend fun scanEvmTransaction(data: TransactionData): CheckTransactionResult =
withContext(dispatchers.io) {
val response = api.scanJsonRpc(mapper.mapToEvmRequest(data))
mapper.mapToDomain(response)
}
private suspend fun scanSolanaTransaction(data: TransactionData): CheckTransactionResult =
withContext(dispatchers.io) {
val response = api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
mapper.mapToDomain(response)
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.api.common.blockaid.models.request.DomainScanReques
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
import com.tangem.datasource.api.common.blockaid.models.response.SolanaTransactionResponse
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -91,7 +92,7 @@ class DefaultBlockAidRepositoryTest {
)
val request = mockk<SolanaTransactionScanRequest>()
val response = mockk<TransactionScanResponse>()
val response = mockk<SolanaTransactionResponse>()
val expectedResult = mockk<CheckTransactionResult>()
every { mapper.mapToSolanaRequest(data) } returns request

View file

@ -40,6 +40,14 @@ internal class BlockAidVerificationDelegate @Inject constructor(
return@flow
}
emit(Lce.Loading(partialContent = null))
val methodName = when (method) {
is WcEthMethod -> rawSdkRequest.request.method
is WcSolanaMethod -> method.trimmedPrefixMethodName
is WcMethod.Unsupported -> {
emit(Lce.Content(failedResult))
return@flow
}
}
val params = when (method) {
is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params)
is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction)
@ -59,7 +67,7 @@ internal class BlockAidVerificationDelegate @Inject constructor(
data = TransactionData(
chain = chain,
accountAddress = accountAddress,
method = rawSdkRequest.request.method,
method = methodName,
domainUrl = session.sdkModel.appMetaData.url,
params = params,
),

View file

@ -2,18 +2,27 @@ package com.tangem.domain.walletconnect.model
sealed interface WcSolanaMethod : WcMethod {
val methodName: String
val trimmedPrefixMethodName: String get() = methodName.substringAfter("_")
data class SignMessage(
val pubKey: String,
val rawMessage: String,
val humanMsg: String,
) : WcSolanaMethod
) : WcSolanaMethod {
override val methodName: String = WcSolanaMethodName.SignMessage.raw
}
data class SignTransaction(
val transaction: String,
val address: String?,
) : WcSolanaMethod
) : WcSolanaMethod {
override val methodName: String = WcSolanaMethodName.SignTransaction.raw
}
data class SignAllTransaction(
val transaction: List<String>,
) : WcSolanaMethod
) : WcSolanaMethod {
override val methodName: String = WcSolanaMethodName.SendAllTransaction.raw
}
}

View file

@ -6,7 +6,6 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import javax.inject.Inject
internal class WcEstimatedWalletChangeUMConverter @Inject constructor() :
@ -17,7 +16,7 @@ internal class WcEstimatedWalletChangeUMConverter @Inject constructor() :
is AmountInfo.FungibleTokens -> WcEstimatedWalletChangeUM(
iconRes = value.iconRes,
title = resourceReference(value.titleRes),
description = "${value.sign} ${amountInfo.amount.amountText()} ${amountInfo.token.symbol}",
description = "${value.sign} ${amountInfo.amountText()} ${amountInfo.token.symbol}",
tokenIconUrl = amountInfo.token.logoUrl,
)
is AmountInfo.NonFungibleTokens -> WcEstimatedWalletChangeUM(
@ -29,7 +28,7 @@ internal class WcEstimatedWalletChangeUMConverter @Inject constructor() :
}
}
private fun BigDecimal.amountText() = format { crypto("", DECIMALS_AMOUNT) }
private fun AmountInfo.FungibleTokens.amountText() = amount.format { crypto("", token.decimals) }
data class Input(
val amountInfo: AmountInfo,

View file

@ -21,7 +21,7 @@ import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal const val DECIMALS_AMOUNT = 2
private const val DECIMALS_AMOUNT = 2
@Suppress("CyclomaticComplexMethod", "LongMethod")
internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor(