Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-18 12:47:25 +04:00
parent 7a8f55b589
commit 2af08f578c
46 changed files with 1035 additions and 0 deletions

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.common.blockaid
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
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.TransactionScanResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface BlockAidApi {
@POST("site/scan")
suspend fun scanDomain(@Body request: DomainScanRequest): DomainScanResponse
@POST("evm/json-rpc/scan")
suspend fun scanJsonRpc(@Body request: EvmTransactionScanRequest): TransactionScanResponse
@POST("solana/message/scan")
suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): TransactionScanResponse
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DomainScanRequest(
@Json(name = "url") val url: String,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
@JsonClass(generateAdapter = true)
data class EvmTransactionScanRequest(
@Json(name = "chain") val chain: String,
@Json(name = "account_address") val accountAddress: String,
@Json(name = "method") val method: String,
@Json(name = "data") val data: RpcData,
@Json(name = "options") val options: List<String> = listOf("simulation", "validation"),
@Json(name = "metadata") val metadata: TransactionMetadata,
)
@JsonClass(generateAdapter = true)
data class RpcData(
@Json(name = "jsonrpc") val jsonrpc: String = "2.0",
@Json(name = "method") val method: String,
@Json(name = "params") val params: String,
)

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.common.blockaid.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
@JsonClass(generateAdapter = true)
data class SolanaTransactionScanRequest(
@Json(name = "encoding") val encoding: String = "base64",
@Json(name = "chain") val chain: String,
@Json(name = "method") val method: String,
@Json(name = "options") val options: List<String> = listOf("simulation, validation"),
@Json(name = "metadata") val metadata: TransactionMetadata,
@Json(name = "account_address") val accountAddress: String,
@Json(name = "transactions") val transactions: List<String>,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccountSummaryResponse(
@Json(name = "assets_diffs") val assetsDiffs: List<AssetDiff>,
@Json(name = "exposures") val exposures: List<Exposure>,
)

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssetDiff(
@Json(name = "asset_type") val assetType: String,
@Json(name = "asset") val asset: Asset,
@Json(name = "in") val inTransfer: List<Transfer>? = null,
@Json(name = "out") val outTransfer: List<Transfer>? = null,
)
@JsonClass(generateAdapter = true)
data class Asset(
@Json(name = "chain_id") val chainId: Int? = null,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "symbol") val symbol: String,
)
@JsonClass(generateAdapter = true)
data class Transfer(
@Json(name = "value") val value: String,
@Json(name = "raw_value") val rawValue: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DomainScanResponse(
@Json(name = "status") val status: String,
@Json(name = "is_malicious") val isMalicious: Boolean?,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Exposure(
@Json(name = "asset") val asset: Asset,
@Json(name = "spenders") val spenders: Map<String, SpenderDetails>,
)
@JsonClass(generateAdapter = true)
data class SpenderDetails(
@Json(name = "exposure") val exposure: List<ExposureDetail>,
@Json(name = "is_approved_for_all") val isApprovedForAll: Boolean? = null,
)
@JsonClass(generateAdapter = true)
data class ExposureDetail(
@Json(name = "value") val value: String,
@Json(name = "raw_value") val rawValue: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SimulationResponse(
@Json(name = "status") val status: String,
@Json(name = "account_summary") val accountSummary: AccountSummaryResponse,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TransactionMetadata(
@Json(name = "domain") val domain: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TransactionScanResponse(
@Json(name = "validation") val validation: ValidationResponse,
@Json(name = "simulation") val simulation: SimulationResponse,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.common.blockaid.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ValidationResponse(
@Json(name = "status") val status: String,
@Json(name = "result_type") val resultType: String,
)

View file

@ -27,6 +27,7 @@ sealed class ApiConfig {
TangemVisaAuth,
TangemVisa,
TangemCardSdk,
BlockAid,
}
private fun initializeId(): ID {
@ -37,6 +38,7 @@ sealed class ApiConfig {
is TangemVisaAuth -> ID.TangemVisaAuth
is TangemVisa -> ID.TangemVisa
is TangemCardSdk -> ID.TangemCardSdk
is BlockAid -> ID.BlockAid
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.ProviderSuspend
internal class BlockAid(
private val environmentConfigStorage: EnvironmentConfigStorage,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val environmentConfigs = listOf(
createProdEnvironment(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
headers = buildMap {
environmentConfigStorage.getConfigSync().blockAidApiKey?.let { apiKey ->
put("X-API-KEY", ProviderSuspend { apiKey })
}
put("accept", ProviderSuspend { "application/json" })
put("content-type", ProviderSuspend { "application/json" })
},
)
}

View file

@ -46,6 +46,12 @@ internal object ApiConfigsModule {
@IntoSet
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider)
@Provides
@IntoSet
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
}
@Provides
@IntoSet
fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk()

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
@ -282,6 +283,29 @@ internal object NetworkModule {
.create(T::class.java)
}
@Provides
@Singleton
fun provideBlockAidApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): BlockAidApi {
return createApi<BlockAidApi>(
id = ApiConfig.ID.BlockAid,
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
).applyTimeoutAnnotations()
},
)
}
private inline fun <reified T> createApi(
id: ApiConfig.ID,
moshi: Moshi,

View file

@ -14,4 +14,5 @@ data class EnvironmentConfig(
val express: ExpressModel? = null,
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
val blockAidApiKey: String? = null,
)

View file

@ -23,6 +23,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
express = value.express,
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
blockAidApiKey = value.blockaidApiKey,
)
}
}

View file

@ -38,6 +38,7 @@ class EnvironmentConfigModel(
@Json(name = "alephiumTangemApiKey") val alephiumTangemApiKey: String?,
@Json(name = "moralisApiKey") val moralisApiKey: String?,
@Json(name = "nftScanApiKey") val nftScanApiKey: String?,
@Json(name = "blockaidApiKey") val blockaidApiKey: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -91,6 +91,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
is TangemVisaAuth -> createVisaAuthModel()
is TangemVisa -> createVisaModel()
is TangemCardSdk -> createTangemCardSdkModel()
is BlockAid -> createBlockAidSdkModel()
}
}
@ -218,6 +219,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
)
}
private fun createBlockAidSdkModel(): Model {
return Model(
id = ApiConfig.ID.BlockAid,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.blockaid.io/v0/",
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]

1
data/blockaid/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,41 @@
plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.android.library)
id("configuration")
}
android {
namespace = "com.tangem.data.blockaid"
}
dependencies {
/* Project - Domain */
implementation(projects.data.common)
implementation(projects.domain.blockaid)
implementation(projects.domain.blockaid.models)
/* Project - Data */
implementation(projects.core.datasource)
/* Project - Core */
implementation(projects.core.utils)
/* DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/* Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/* Tests */
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
}

View file

@ -0,0 +1,128 @@
package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.transaction.*
import com.domain.blockaid.models.transaction.simultation.ApprovedAmount
import com.domain.blockaid.models.transaction.simultation.SimulationData
import com.domain.blockaid.models.transaction.simultation.TokenInfo
import com.domain.blockaid.models.transaction.simultation.AmountInfo
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.request.RpcData
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.*
import javax.inject.Inject
private const val SUCCESS_STATUS = "Success"
private const val DOMAIN_CHECKED_STATUS = "hit"
private const val VALIDATION_SAFE_STATUS = "Benign"
internal class BlockAidMapper @Inject constructor() {
fun mapToDomain(from: DomainScanResponse): CheckDAppResult {
return when {
from.status != DOMAIN_CHECKED_STATUS -> CheckDAppResult.FAILED_TO_VERIFY
from.isMalicious == true -> CheckDAppResult.UNSAFE
else -> CheckDAppResult.SAFE
}
}
fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult {
return CheckTransactionResult(
validation = when {
from.validation.status != SUCCESS_STATUS -> ValidationResult.FAILED_TO_VALIDATE
from.validation.resultType == VALIDATION_SAFE_STATUS -> ValidationResult.SAFE
else -> ValidationResult.UNSAFE
},
simulation = if (from.simulation.status != SUCCESS_STATUS) {
SimulationResult.FailedToSimulate
} else {
mapSimulationSuccessResult(from.simulation.accountSummary)
},
)
}
fun mapToEvmRequest(from: TransactionData): EvmTransactionScanRequest {
return EvmTransactionScanRequest(
chain = from.chain,
accountAddress = from.accountAddress,
method = from.method,
data = RpcData(
method = from.method,
params = (from.params as TransactionParams.Evm).params,
),
metadata = TransactionMetadata(from.domainUrl),
)
}
fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest {
return SolanaTransactionScanRequest(
chain = from.chain,
accountAddress = from.accountAddress,
metadata = TransactionMetadata(from.domainUrl),
method = from.method,
transactions = (from.params as TransactionParams.Solana).transactions,
)
}
private fun mapSimulationSuccessResult(from: AccountSummaryResponse): SimulationResult {
return when {
from.assetsDiffs.isEmpty() && from.exposures.isNotEmpty() -> mapApproveTransaction(from.exposures)
from.assetsDiffs.isNotEmpty() && from.exposures.isEmpty() -> mapSendReceiveTransaction(from.assetsDiffs)
else -> SimulationResult.FailedToSimulate
}
}
private fun mapApproveTransaction(exposures: List<Exposure>): SimulationResult {
val amounts = exposures.flatMap { exposure ->
val tokenInfo = TokenInfo(
chainId = exposure.asset.chainId,
logoUrl = exposure.asset.logoUrl,
symbol = exposure.asset.symbol,
)
exposure.spenders.flatMap { (_, spender) ->
val isUnlimited = spender.isApprovedForAll == true
spender.exposure.mapNotNull { detail ->
ApprovedAmount(
approvedAmount = detail.value.toBigDecimalOrNull() ?: return@mapNotNull null,
isUnlimited = isUnlimited,
tokenInfo = tokenInfo,
)
}
}
}
return if (amounts.isNotEmpty()) {
SimulationResult.Success(SimulationData.Approve(amounts))
} else {
SimulationResult.FailedToSimulate
}
}
private fun mapSendReceiveTransaction(assetDiffs: List<AssetDiff>): SimulationResult {
val sendInfo = arrayListOf<AmountInfo>()
val receiveInfo = arrayListOf<AmountInfo>()
assetDiffs.forEach { diff ->
val token = TokenInfo(
chainId = diff.asset.chainId,
logoUrl = diff.asset.logoUrl,
symbol = diff.asset.symbol,
)
diff.outTransfer.orEmpty().forEach { transfer ->
transfer.value.toBigDecimalOrNull()?.let { amount ->
sendInfo.add(AmountInfo(amount = amount, token = token))
}
}
diff.inTransfer.orEmpty().forEach { transfer ->
transfer.value.toBigDecimalOrNull()?.let { amount ->
receiveInfo.add(AmountInfo(amount = amount, token = token))
}
}
}
return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) {
SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo))
} else {
SimulationResult.FailedToSimulate
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.TransactionData
interface BlockAidRepository {
suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult
suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult
}

View file

@ -0,0 +1,40 @@
package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.TransactionData
import com.domain.blockaid.models.transaction.TransactionParams
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultBlockAidRepository @Inject constructor(
private val api: BlockAidApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val mapper: BlockAidMapper,
) : BlockAidRepository {
override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult {
val response = withContext(dispatcherProvider.io) {
api.scanDomain(DomainScanRequest(data.url))
}
return mapper.mapToDomain(response)
}
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
val response = withContext(dispatcherProvider.io) {
when (data.params) {
is TransactionParams.Evm -> {
api.scanJsonRpc(mapper.mapToEvmRequest(data))
}
is TransactionParams.Solana -> {
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
}
}
}
return mapper.mapToDomain(response)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.blockaid
import arrow.core.Either
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.TransactionData
import com.tangem.domain.blockaid.BlockAidVerifier
import javax.inject.Inject
/**
* Verifies the safety of DApps and WalletConnect transactions
*/
class DefaultBlockAidVerifier @Inject constructor(
private val repository: BlockAidRepository,
) : BlockAidVerifier {
/**
* Checks if a DApp is safe to use
*/
override suspend fun verifyDApp(data: DAppData): Either<Throwable, CheckDAppResult> {
return Either.catch { repository.verifyDAppDomain(data) }
}
/**
* Checks the safety of a WalletConnect transaction and provides a simulation result
*/
override suspend fun verifyTransaction(data: TransactionData): Either<Throwable, CheckTransactionResult> {
return Either.catch { repository.verifyTransaction(data) }
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.blockaid.di
import com.tangem.data.blockaid.BlockAidRepository
import com.tangem.data.blockaid.DefaultBlockAidRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface BlockAidDataInternalModule {
@Binds
@Singleton
fun bindRepository(repository: DefaultBlockAidRepository): BlockAidRepository
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.blockaid.di
import com.tangem.data.blockaid.DefaultBlockAidVerifier
import com.tangem.domain.blockaid.BlockAidVerifier
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface BlockAidDataModule {
@Binds
@Singleton
fun bindVerifier(verifier: DefaultBlockAidVerifier): BlockAidVerifier
}

View file

@ -0,0 +1,155 @@
package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.transaction.SimulationResult
import com.domain.blockaid.models.transaction.ValidationResult
import com.domain.blockaid.models.transaction.simultation.SimulationData
import com.tangem.datasource.api.common.blockaid.models.response.*
import org.junit.Assert.*
import org.junit.Test
import java.math.BigDecimal
class BlockAidMapperTest {
private val mapper = BlockAidMapper()
@Test
fun whenStatusHitAndIsMaliciousFalseThenMapToDomainReturnsSafe() {
val response = DomainScanResponse(status = "hit", isMalicious = false)
val result = mapper.mapToDomain(response)
assertEquals(CheckDAppResult.SAFE, result)
}
@Test
fun whenStatusHitAndIsMaliciousTrueThenMapToDomainReturnsUnsafe() {
val response = DomainScanResponse(status = "hit", isMalicious = true)
val result = mapper.mapToDomain(response)
assertEquals(CheckDAppResult.UNSAFE, result)
}
@Test
fun whenStatusNotHitThenMapToDomainReturnsFailedToVerify() {
val response = DomainScanResponse(status = "miss", isMalicious = false)
val result = mapper.mapToDomain(response)
assertEquals(CheckDAppResult.FAILED_TO_VERIFY, result)
}
@Test
fun whenResponseBenignValidationThenReturnsSafeValidation() {
val spenderDetails = SpenderDetails(
isApprovedForAll = true,
exposure = listOf(ExposureDetail(value = "1000.0", rawValue = "0x123")),
)
val exposure = Exposure(
asset = Asset(chainId = 1, logoUrl = "logo", symbol = "PEPE"),
spenders = mapOf("spender" to spenderDetails),
)
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(assetsDiffs = emptyList(), exposures = listOf(exposure)),
),
)
val result = mapper.mapToDomain(response)
assertEquals(ValidationResult.SAFE, result.validation)
val simulation = result.simulation as? SimulationResult.Success
assertNotNull(simulation)
val approve = simulation?.data as? SimulationData.Approve
assertNotNull(approve)
assertEquals(1, approve?.approvedAmounts?.size)
assertEquals(BigDecimal("1000.0"), approve?.approvedAmounts?.first()?.approvedAmount)
assertTrue(approve?.approvedAmounts?.first()?.isUnlimited == true)
}
@Test
fun whenResponseBenignValidationAndSuccessSimulationThenReturnsSendReceiveResult() {
val assetDiff = AssetDiff(
assetType = "ERC20",
asset = Asset(chainId = 1, logoUrl = "logo", symbol = "ETH"),
inTransfer = listOf(Transfer(value = "2.0", rawValue = "0x1")),
outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")),
)
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(exposures = emptyList(), assetsDiffs = listOf(assetDiff)),
),
)
val result = mapper.mapToDomain(response)
assertEquals(ValidationResult.SAFE, result.validation)
val simulation = result.simulation as? SimulationResult.Success
assertNotNull(simulation)
val data = simulation?.data as? SimulationData.SendAndReceive
assertNotNull(data)
assertEquals(BigDecimal("1.5"), data?.send?.first()?.amount)
assertEquals(BigDecimal("2.0"), data?.receive?.first()?.amount)
}
@Test
fun whenResponseErrorValidationThenReturnsFailedToValidate() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Error", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
assertEquals(ValidationResult.FAILED_TO_VALIDATE, result.validation)
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
}
@Test
fun whenResponseNotBenignThenReturnsValidationUnsafe() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Phishing"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
assertEquals(ValidationResult.UNSAFE, result.validation)
}
@Test
fun whenResponseSimulationNotSuccessThenReturnsSimulationFailedToSimulate() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Error",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
}
@Test
fun whenResponseSimulationIsEmptyThenReturnsFailedToSimulate() {
val txResponse = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(
assetsDiffs = emptyList(),
exposures = emptyList(),
),
),
)
val result = mapper.mapToDomain(txResponse)
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.TransactionData
import com.domain.blockaid.models.transaction.TransactionParams
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
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.TransactionScanResponse
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultBlockAidRepositoryTest {
@MockK
private lateinit var api: BlockAidApi
@MockK
private lateinit var mapper: BlockAidMapper
@MockK
private lateinit var dispatcherProvider: CoroutineDispatcherProvider
private lateinit var repository: DefaultBlockAidRepository
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
MockKAnnotations.init(this)
Dispatchers.setMain(testDispatcher)
every { dispatcherProvider.io } returns testDispatcher
repository = DefaultBlockAidRepository(api, dispatcherProvider, mapper)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun whenVerifyAppDomainThenCallsApiAndMapsResult() = runTest {
val url = "https://example.com"
val domainData = DAppData(url)
val domainResponse = DomainScanResponse(status = "hit", isMalicious = false)
val expectedResult = CheckDAppResult.SAFE
coEvery { api.scanDomain(DomainScanRequest(url)) } returns domainResponse
every { mapper.mapToDomain(domainResponse) } returns expectedResult
val result = repository.verifyDAppDomain(domainData)
assertEquals(expectedResult, result)
coVerify { api.scanDomain(DomainScanRequest(url)) }
verify { mapper.mapToDomain(domainResponse) }
}
@Test
fun whenVerifyEvmTransactionThenCallsScanJsonRpcAndMaps() = runTest {
val data = TransactionData(
chain = "ethereum",
accountAddress = "0xabc",
domainUrl = "https://uniswap.org",
method = "eth_sendTransaction",
params = TransactionParams.Evm(params = "some-params"),
)
val request = mockk<EvmTransactionScanRequest>()
val response = mockk<TransactionScanResponse>()
val expectedResult = mockk<CheckTransactionResult>()
every { mapper.mapToEvmRequest(data) } returns request
coEvery { api.scanJsonRpc(request) } returns response
every { mapper.mapToDomain(response) } returns expectedResult
val result = repository.verifyTransaction(data)
assertEquals(expectedResult, result)
coVerify { api.scanJsonRpc(request) }
verify { mapper.mapToEvmRequest(data) }
verify { mapper.mapToDomain(response) }
}
@Test
fun whenVerifySolanaTransactionThenCallsScanSolanaMessageAndMapsResult() = runTest {
val data = TransactionData(
chain = "mainnet",
accountAddress = "/Rd2TLl...",
domainUrl = "https://example.com",
method = "signTransaction",
params = TransactionParams.Solana(transactions = listOf("TX_PAYLOAD_BASE64")),
)
val request = mockk<SolanaTransactionScanRequest>()
val response = mockk<TransactionScanResponse>()
val expectedResult = mockk<CheckTransactionResult>()
every { mapper.mapToSolanaRequest(data) } returns request
coEvery { api.scanSolanaMessage(request) } returns response
every { mapper.mapToDomain(response) } returns expectedResult
val result = repository.verifyTransaction(data)
assertEquals(expectedResult, result)
coVerify { api.scanSolanaMessage(request) }
verify { mapper.mapToSolanaRequest(data) }
verify { mapper.mapToDomain(response) }
}
}

1
domain/blockaid/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
/* Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.blockaid.models)
/* Other */
implementation(deps.moshi.adapters)
}

1
domain/blockaid/models/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,10 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.ksp)
id("configuration")
}
dependencies {
/* Other */
implementation(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
}

View file

@ -0,0 +1,22 @@
package com.domain.blockaid.models.dapp
/**
* Result of BlockAid's DApp domain check
*/
enum class CheckDAppResult {
/**
* DApp was confirmed safe
*/
SAFE,
/**
* DApp was confirmed unsafe (known security risk)
*/
UNSAFE,
/**
* Check wasn't performed, BlockAid cannot guarantee DApp's safety
*/
FAILED_TO_VERIFY,
}

View file

@ -0,0 +1,11 @@
package com.domain.blockaid.models.dapp
/**
* Data BlockAid needs to verify DApp domain
*
* @property url DApp's domain url
*/
@JvmInline
value class DAppData(
val url: String,
)

View file

@ -0,0 +1,12 @@
package com.domain.blockaid.models.transaction
/**
* Result of BlockAid's transaction check
*
* @property validation Indicates whether the transaction is considered safe or unsafe
* @property simulation Provides insight into the expected outcome of the transaction
*/
data class CheckTransactionResult(
val validation: ValidationResult,
val simulation: SimulationResult,
)

View file

@ -0,0 +1,21 @@
package com.domain.blockaid.models.transaction
import com.domain.blockaid.models.transaction.simultation.SimulationData
/**
* Result of BlockAid's transaction simulation
*/
sealed class SimulationResult {
/**
* Simulation was successfully performed and returned data
*/
data class Success(
val data: SimulationData,
) : SimulationResult()
/**
* Simulation wasn't performed, BlockAid cannot guarantee transaction's behavior
*/
data object FailedToSimulate : SimulationResult()
}

View file

@ -0,0 +1,36 @@
package com.domain.blockaid.models.transaction
/**
* Input data for BlockAid's transaction check
*
* @property chain Chain name, for ex. "ethereum"
* @property accountAddress The address of the account (wallet) received the request in hex string format
* @property method Transaction method, for ex. "eth_signTransaction"
* @property domainUrl Url of the DApp domain
*/
data class TransactionData(
val chain: String,
val accountAddress: String,
val method: String,
val domainUrl: String,
val params: TransactionParams,
)
sealed class TransactionParams {
/**
* Parameters for Ethereum based transactions, can be taken from [WcSdkSessionRequest.JSONRPCRequest.params]
*/
data class Evm(
val params: String,
) : TransactionParams()
/**
* Parameters for Solana transactions
*
* @property transactions Base64-encoded serialized list of transactions
*/
data class Solana(
val transactions: List<String>,
) : TransactionParams()
}

View file

@ -0,0 +1,22 @@
package com.domain.blockaid.models.transaction
/**
* Result of BlockAid's transaction validation
*/
enum class ValidationResult {
/**
* Transaction was confirmed safe
*/
SAFE,
/**
* Transaction was confirmed unsafe
*/
UNSAFE,
/**
* Validation wasn't performed, BlockAid cannot guarantee transaction's safety
*/
FAILED_TO_VALIDATE,
}

View file

@ -0,0 +1,8 @@
package com.domain.blockaid.models.transaction.simultation
import java.math.BigDecimal
data class AmountInfo(
val amount: BigDecimal,
val token: TokenInfo,
)

View file

@ -0,0 +1,9 @@
package com.domain.blockaid.models.transaction.simultation
import java.math.BigDecimal
data class ApprovedAmount(
val approvedAmount: BigDecimal,
val isUnlimited: Boolean,
val tokenInfo: TokenInfo,
)

View file

@ -0,0 +1,22 @@
package com.domain.blockaid.models.transaction.simultation
/**
* Result of a successful transaction simulation.
*/
sealed class SimulationData {
/**
* Represents a swap/send/sell operations with specified send and receive amounts (can be multiple amounts for NFT)
*/
data class SendAndReceive(
val send: List<AmountInfo>,
val receive: List<AmountInfo>,
) : SimulationData()
/**
* Represents an approve operation with the specified amount (can be multiple amounts for NFT)
*/
data class Approve(
val approvedAmounts: List<ApprovedAmount>,
) : SimulationData()
}

View file

@ -0,0 +1,7 @@
package com.domain.blockaid.models.transaction.simultation
data class TokenInfo(
val chainId: Int?,
val logoUrl: String?,
val symbol: String,
)

View file

@ -0,0 +1,23 @@
package com.tangem.domain.blockaid
import arrow.core.Either
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.TransactionData
/**
* Verifies the safety of DApps and WalletConnect transactions
*/
interface BlockAidVerifier {
/**
* Checks if a DApp is safe to use
*/
suspend fun verifyDApp(data: DAppData): Either<Throwable, CheckDAppResult>
/**
* Checks the safety of a WalletConnect transaction and provides a simulation result
*/
suspend fun verifyTransaction(data: TransactionData): Either<Throwable, CheckTransactionResult>
}

View file

@ -289,6 +289,8 @@ include(":domain:nft")
include(":domain:nft:models")
include(":domain:networks")
include(":domain:quotes")
include(":domain:blockaid")
include(":domain:blockaid:models")
// endregion Domain modules
// region Data modules
@ -316,4 +318,5 @@ include(":data:networks")
include(":data:nft")
include(":data:onramp")
include(":data:quotes")
include(":data:blockaid")
// endregion Data modules