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

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) }
}
}