Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-18 17:08:11 +03:00
commit dbb63a396f
448 changed files with 12230 additions and 2075 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) }
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.feedback.converters
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TapWorkarounds.isVisa
import com.tangem.domain.common.util.getBackupCardsCount
import com.tangem.domain.feedback.models.CardInfo
import com.tangem.domain.models.scan.CardDTO
@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter<ScanResponse, CardInfo> {
},
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.card.isStart2Coin,
isVisa = value.card.isVisa,
)
}
}

View file

@ -65,7 +65,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -95,7 +95,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -112,7 +112,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(updatedStatuses.map(NetworkStatus::toSimple).toSet())
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -143,7 +143,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -155,7 +155,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// second emit
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -191,7 +191,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -202,7 +202,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
innerFlow.emit(value = true)
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -222,7 +222,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) }
@ -248,7 +248,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.

View file

@ -53,7 +53,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(status))
@ -74,7 +74,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
@ -83,7 +83,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
@ -104,7 +104,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
@ -112,7 +112,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
// second emit
expected.emit(value = setOf(status))
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
@ -140,7 +140,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
@ -148,7 +148,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
innerFlow.emit(value = true)
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@ -166,7 +166,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
}

View file

@ -30,7 +30,7 @@ internal class NetworksStatusesStoreGetMethodTest {
fun `test get if runtime store is empty`() = runTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<SimpleNetworkStatus>>())
}
@ -41,7 +41,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<SimpleNetworkStatus>>())
}
@ -54,7 +54,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<SimpleNetworkStatus>()))
@ -70,7 +70,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(status.toSimple())))

View file

@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest {
DefaultNetworksStatusesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore, // local mock
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
@ -117,7 +118,7 @@ internal class DefaultNFTRepository @Inject constructor(
assets.forEach {
val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier)
val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId)
if (price is NFTSalePrice.Error) {
if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) {
refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier)
}
}
@ -155,6 +156,12 @@ internal class DefaultNFTRepository @Inject constructor(
override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs()
override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? =
walletManagersFacade.getNFTExploreUrl(
network = network,
assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier),
)
private suspend fun refreshSalePrice(
userWalletId: UserWalletId,
network: Network,

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,36 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.quotes"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.data.common)
implementation(projects.data.tokens)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.quotes)
implementation(projects.domain.wallets.models)
implementation(deps.androidx.datastore)
implementation(deps.timber)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.quotes
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.quotes.QuotesRepositoryV2
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import javax.inject.Inject
/**
* Default implementation of [QuotesRepositoryV2]
*
* @property quotesStore quotes store
*
[REDACTED_AUTHOR]
*/
internal class DefaultQuotesRepositoryV2 @Inject constructor(
private val quotesStore: QuotesStoreV2,
) : QuotesRepositoryV2 {
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote>? {
return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) {
it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId)
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
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 QuoteFetcherModule {
@Binds
@Singleton
fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher
@Binds
@Singleton
fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.single.DefaultSingleQuoteProducer
import com.tangem.domain.quotes.single.SingleQuoteProducer
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 QuoteProducerFactoryModule {
@Binds
@Singleton
fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory
}

View file

@ -0,0 +1,42 @@
package com.tangem.data.quotes.di
import androidx.datastore.core.DataStore
import com.tangem.data.quotes.store.CurrencyIdWithQuote
import com.tangem.data.quotes.store.DefaultQuotesStoreV2
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.quotes.single.SingleQuoteSupplier
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QuoteSupplierModule {
@Provides
@Singleton
fun provideQuotesStoreV2(
persistenceQuotesStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
): QuotesStoreV2 {
return DefaultQuotesStoreV2(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = persistenceQuotesStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier {
return object : SingleQuoteSupplier(
factory = factory,
keyCreator = { "single_quote_${it.rawCurrencyId.value}" },
) {}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.DefaultQuotesRepositoryV2
import com.tangem.domain.quotes.QuotesRepositoryV2
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 QuotesDataModule {
@Binds
@Singleton
fun bindQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.quotes.multi
import arrow.core.Either
import com.tangem.data.common.api.safeApiCallWithTimeout
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteFetcher]
*
* @property tangemTechApi tangemTech api
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultMultiQuoteFetcher @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteFetcher {
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) {
if (params.currenciesIds.isEmpty()) {
Timber.d("No currencies to fetch quotes for")
return@catchOn
}
quotesStore.refresh(currenciesIds = params.currenciesIds)
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
currenciesIds = params.currenciesIds.mapTo(
destination = hashSetOf(),
transform = CryptoCurrency.RawID::value,
),
)
val appCurrencyId = getAppCurrencyId(params = params)
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
val response = safeApiCallWithTimeout(
call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() },
onError = { error -> throw error },
)
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response = response,
filteredIds = replacementIdsResult.idsFiltered,
)
quotesStore.storeActual(values = updatedResponse.quotes)
}
.onLeft {
Timber.e(it)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String {
val appCurrencyId = params.appCurrencyId
?: appCurrencyResponseStore.getSyncOrNull()?.id
if (appCurrencyId.isNullOrBlank()) {
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
Timber.e(exception)
throw exception
}
return appCurrencyId
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.data.quotes.multi
import androidx.annotation.VisibleForTesting
import arrow.core.left
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes
*
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property multiQuoteFetcher multi quote fetcher
* @param dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultMultiQuoteUpdater @Inject constructor(
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val multiQuoteFetcher: MultiQuoteFetcher,
dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteUpdater {
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val updaterHolder = JobHolder()
override fun subscribe() {
Timber.d("Subscribe on quotes updates")
getMultiQuoteUpdates()
.launchIn(coroutineScope)
.saveIn(updaterHolder)
}
override fun unsubscribe() {
Timber.e("Unsubscribe from quotes updates")
updaterHolder.cancel()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getMultiQuoteUpdates(): EitherFlow<Throwable, Unit> {
return appCurrencyResponseStore.get()
.drop(count = 1) // skip initial value
.distinctUntilChanged()
.filterNotNull()
.mapLatest { appCurrency ->
val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty()
.mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId)
multiQuoteFetcher(
params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id),
)
.onLeft(Timber::e)
}
.retryWhen { cause, _ ->
Timber.e("Retry updating quotes: $cause")
emit(cause.left())
delay(timeMillis = 2000)
true
}
}
@VisibleForTesting(VisibleForTesting.NONE)
fun getMultiQuoteUpdatesFlow(): EitherFlow<Throwable, Unit> = getMultiQuoteUpdates()
@VisibleForTesting(VisibleForTesting.NONE)
fun getUpdaterJobHolder(): JobHolder = updaterHolder
}

View file

@ -0,0 +1,40 @@
package com.tangem.data.quotes.single
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleQuoteProducer]
*
* @property params params
* @property quotesStore quotes store
*/
internal class DefaultSingleQuoteProducer @AssistedInject constructor(
@Assisted val params: SingleQuoteProducer.Params,
private val quotesStore: QuotesStoreV2,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleQuoteProducer {
override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
override fun produce(): Flow<Quote> {
return quotesStore.get()
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : SingleQuoteProducer.Factory {
override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.quote.converter.QuoteConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
/**
* Default implementation of [QuotesStoreV2]
*
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
*/
internal class DefaultQuotesStoreV2(
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
) : QuotesStoreV2 {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceDataStore.data.firstOrNull()
if (cachedStatuses.isNullOrEmpty()) return@launch
runtimeStore.store(
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
)
}
}
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
}
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
coroutineScope {
launch {
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
storeInRuntimeStore(values = quotes)
}
launch { storeInPersistenceStore(values = values) }
}
}
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE)
}
private suspend fun updateStatusSourceInRuntime(currenciesIds: Set<CryptoCurrency.RawID>, source: StatusSource) {
runtimeStore.update(default = emptySet()) { stored ->
val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id ->
val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id)
quote.copySealed(source = source)
}
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
runtimeStore.update(default = emptySet()) { saved ->
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.quotes.store
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import kotlinx.coroutines.flow.Flow
/** Store of [Quote]'es set */
internal interface QuotesStoreV2 {
/** Get flow of quotes */
fun get(): Flow<Set<Quote>>
/** Get all quotes synchronously or null */
suspend fun getAllSyncOrNull(): Set<Quote>?
/** Refresh status of [currenciesIds] */
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)
/** Store actual map of currency ids and quotes [values] */
suspend fun storeActual(values: Map<String, QuotesResponse.Quote>)
/** Store error for [currenciesIds] */
suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>)
}

View file

@ -0,0 +1,214 @@
package com.tangem.data.quotes.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteFetcherTest {
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
private val fetcher = DefaultMultiQuoteFetcher(
tangemTechApi = tangemTechApi,
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `fetch quotes successfully`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
val coinIds = "BTC,ETH"
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
} returns ApiResponse.Success(successResponse)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeActual(values = successResponse.quotes)
}
coVerify(inverse = true) {
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
val actual = fetcher(params)
coVerify(inverse = true) {
quotesStore.refresh(currenciesIds = any())
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest {
val appCurrencyId = "usd"
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val coinIds = "BTC,ETH"
coEvery {
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds)
} returns ApiResponse.Success(successResponse)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeActual(values = successResponse.quotes)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest {
val appCurrencyId = ""
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat()
.isEqualTo("Unable to get AppCurrency for updating quotes")
}
@Test
fun `fetch quotes failure because api request failed`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
val coinIds = "BTC,ETH"
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<QuotesResponse>
coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
}
@Test
fun `fetch quotes failure because app currency not found`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
}
private companion object {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
val usdAppCurrency = CurrenciesResponse.Currency(
id = "USD".lowercase(),
code = "USD",
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
)
val successResponse = QuotesResponse(
quotes = mapOf(
"BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
"ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN),
),
)
}
}

View file

@ -0,0 +1,169 @@
package com.tangem.data.quotes.multi
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteUpdaterTest {
private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk()
private val quotesStore: QuotesStoreV2 = mockk()
private val multiQuoteFetcher: MultiQuoteFetcher = mockk()
private val multiQuoteUpdater = DefaultMultiQuoteUpdater(
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
multiQuoteFetcher = multiQuoteFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test that initial app currency is skipped`() = runTest {
val appCurrencyFlow = flowOf(null, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val appCurrencyFlow = flowOf(usdAppCurrency, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the null`() = runTest {
val appCurrencyFlow = flowOf(null, null)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values.size).isEqualTo(0)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val innerFlow = MutableStateFlow(value = false)
val appCurrencyFlow = flow {
if (innerFlow.value) {
emitAll(flowOf(null, usdAppCurrency))
} else {
throw exception
}
}
.buffer(capacity = 5)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values1 = getEmittedValues(actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first().isLeft()).isTrue()
Truth.assertThat(values1.first().leftOrNull()).isInstanceOf(exception::class.java)
Truth.assertThat(values1.first().leftOrNull()).hasMessageThat().isEqualTo(exception.message)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `subscribe and unsubscribe successfully`() {
every { appCurrencyResponseStore.get() } returns emptyFlow()
val updaterJobHolder = multiQuoteUpdater.getUpdaterJobHolder()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
multiQuoteUpdater.subscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isFalse()
multiQuoteUpdater.unsubscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
}
private companion object {
val usdAppCurrency = CurrenciesResponse.Currency(
id = "USD".lowercase(),
code = "USD",
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
)
}
}

View file

@ -0,0 +1,177 @@
package com.tangem.data.quotes.single
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.models.StatusSource
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class DefaultSingleQuoteProducerTest {
private val params = SingleQuoteProducer.Params(
rawCurrencyId = CryptoCurrency.RawID(value = "BTC"),
)
private val quotesStore = mockk<QuotesStoreV2>()
private val producer = DefaultSingleQuoteProducer(
params = params,
quotesStore = quotesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test that flow is mapped for network from params`() = runTest {
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
val storeQuote = flowOf(
setOf(
status,
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
every { quotesStore.get() } returns storeQuote
val actual = producer.produce()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(status))
}
@Test
fun `test that flow is updated if quote is updated`() = runTest {
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = Quote.Value(
rawCurrencyId = params.rawCurrencyId,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
)
storeQuote.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
storeQuote.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val status = Quote.Value(
rawCurrencyId = params.rawCurrencyId,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
)
val innerFlow = MutableStateFlow(value = false)
val storeQuote = flow {
if (innerFlow.value) {
emit(setOf(status))
} else {
throw exception
}
}
.buffer(capacity = 5)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@Test
fun `test if flow doesn't contain network from params`() = runTest {
val storeFlow = flowOf(
setOf(
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
every { quotesStore.get() } returns storeFlow
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test get if runtime store is empty`() = runTest {
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<Quote>>())
}
@Test
fun `test get if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(listOf(emptySet<Quote>()))
}
@Test
fun `test get if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
}
@Test
fun `test getAllSyncOrNull if runtime store is empty`() = runTest {
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(null)
}
@Test
fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(emptySet<Quote>())
}
@Test
fun `test getAllSyncOrNull if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.getAllSyncOrNull()
val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain())
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
persistenceStore.updateData {
it.toMutableMap().apply {
this += btcQuote
this += ethQuote
}
}
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val expected = setOf(
btcQuote.toDomain(source = StatusSource.CACHE),
ethQuote.toDomain(source = StatusSource.CACHE),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `refresh if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.refresh(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `refresh if runtime store contains quote with this id`() = runTest {
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
runtimeStore.store(value = setOf(quote))
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
),
)
persistenceStore.updateData {
it.toMutableMap().apply {
put("BTC", prevStatus)
}
}
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
store.storeActual(values = mapOf("BTC" to newStatus))
val runtimeExpected = setOf(
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
)
val persistenceExpected = mapOf("BTC" to newStatus)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
}
@Test
fun `store error if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.storeError(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store error if runtime store contains status with this network`() = runTest {
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
store.storeError(
currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
),
)
val runtimeExpected = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
}

View file

@ -62,4 +62,11 @@ dependencies {
}
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,44 @@
package com.tangem.data.staking.multi
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEmpty
/**
* Default implementation of [MultiYieldBalanceProducer]
*
* @property params params
* @property yieldsBalancesStore yields balances store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor(
@Assisted val params: MultiYieldBalanceProducer.Params,
private val yieldsBalancesStore: YieldsBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiYieldBalanceProducer {
override val fallback: Set<YieldBalance>
get() = setOf()
override fun produce(): Flow<Set<YieldBalance>> {
return yieldsBalancesStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiYieldBalanceProducer.Factory {
override fun create(params: MultiYieldBalanceProducer.Params): DefaultMultiYieldBalanceProducer
}
}

View file

@ -0,0 +1,158 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.YieldsBalancesStore.StakingID
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
internal typealias WalletIdWithWrappers = Map<String, Set<YieldBalanceWrapperDTO>>
internal typealias WalletIdWithBalances = Map<UserWalletId, Set<YieldBalance>>
/**
* Default implementation of [YieldsBalancesStore]
*
* @property runtimeStore runtime store
* @property persistenceStore persistence store
* @param dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultYieldsBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithBalances>,
private val persistenceStore: DataStore<WalletIdWithWrappers>,
dispatchers: CoroutineDispatcherProvider,
) : YieldsBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.map { (stringWalletId, wrappers) ->
val key = UserWalletId(stringWalletId)
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
key to value
}
.toMap(),
)
}
}
override fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>> {
return runtimeStore.get().mapNotNull { it[userWalletId] }
}
override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) {
updateBalanceInRuntime(userWalletId, stakingId) {
it.copySealed(source = StatusSource.CACHE)
}
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val storedBalances = this[userWalletId].orEmpty()
val balances = stakingIds.mapTo(hashSetOf()) { stakingId ->
val balance = storedBalances.firstOrNull {
it.integrationId == stakingId.integrationId &&
it.address == stakingId.address
}
?: createDefaultBalance(id = stakingId)
balance.copySealed(source = StatusSource.CACHE)
}
val updatedBalances = storedBalances.addOrReplace(balances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
put(key = userWalletId, value = updatedBalances)
}
}
}
override suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, values = values) }
launch { storeInPersistence(userWalletId = userWalletId, values = values) }
}
}
override suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID) {
updateBalanceInRuntime(userWalletId, stakingId) {
it.copySealed(source = StatusSource.ONLY_CACHE)
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
?: newBalances
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(items = values) { old, new ->
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
}
?: values
}
}
}
private suspend fun updateBalanceInRuntime(
userWalletId: UserWalletId,
stakingID: StakingID,
update: (YieldBalance) -> YieldBalance,
) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val balance = this[userWalletId].orEmpty()
.firstOrNull {
it.integrationId == stakingID.integrationId &&
it.address == stakingID.address
}
?: createDefaultBalance(id = stakingID)
val updatedBalances = this[userWalletId].orEmpty()
.addOrReplace(item = update(balance)) {
it.integrationId == balance.integrationId &&
it.address == balance.address
}
put(key = userWalletId, value = updatedBalances)
}
}
}
private fun createDefaultBalance(id: StakingID): YieldBalance {
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store of [YieldBalance]'s set
*
[REDACTED_AUTHOR]
*/
interface YieldsBalancesStore {
/** Get flow of [YieldBalance]'s set by [userWalletId] */
fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>>
/** Refresh balance of [stakingId] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
/** Refresh balances of [stakingIds] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Store actual [values] by [userWalletId] */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
/** Store error by [userWalletId] and [stakingId] */
suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID)
data class StakingID(val integrationId: String, val address: String)
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.staking
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): YieldBalance {
return YieldBalanceConverter(source = source).convert(this)
}

View file

@ -0,0 +1,190 @@
package com.tangem.data.staking.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceProducerTest {
private val params = MultiYieldBalanceProducer.Params(userWalletId = UserWalletId("011"))
private val yieldsBalancesStore = mockk<YieldsBalancesStore>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiYieldBalanceProducer(
params = params,
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
@Test
fun `test that flow is mapped for user wallet id from params`() = runTest {
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val networksStatusesFlow = flowOf(balances)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(balances)
}
@Test
fun `test that flow is updated if balances are updated`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
// first emit
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(balances)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(balances)
// second emit
val updatedWrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(updatedWrappers)
val values2 = getEmittedValues(flow = actual)
val expected = listOf(balances, updatedWrappers)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(expected)
}
@Test
fun `test that flow is filtered the same balance`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(wrappers)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(wrappers)
// second emit
networksStatusesFlow.emit(wrappers)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2.first()).isEqualTo(wrappers)
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val innerFlow = MutableStateFlow(value = false)
val networksStatusesFlow = flow {
if (innerFlow.value) {
emit(balances)
} else {
throw exception
}
}
.buffer(capacity = 5)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produceWithFallback()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(emptySet<YieldBalance>()))
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balances))
}
@Test
fun `test that flow is empty`() = runTest {
every { yieldsBalancesStore.get(params.userWalletId) } returns emptyFlow()
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
}
private companion object {
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = YieldsBalancesStore.StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
}
}

View file

@ -0,0 +1,83 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test get if runtime store is empty`() = runTest {
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<YieldBalance>>())
}
@Test
fun `test get if runtime store contains empty map`() = runTest {
runtimeStore.store(value = emptyMap())
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<YieldBalance>>())
}
@Test
fun `test get if runtime store contains portfolio with empty balances`() = runTest {
runtimeStore.store(
value = mapOf(userWalletId to emptySet()),
)
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
}
@Test
fun `test get if runtime store is not empty`() = runTest {
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
runtimeStore.store(
value = mapOf(userWalletId to setOf(wrapper.toDomain())),
)
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(wrapper.toDomain())))
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore: DataStore<WalletIdWithWrappers> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<YieldBalance>>())
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
persistenceStore.updateData {
it.toMutableMap().apply {
put(userWalletId.stringValue, setOf(wrapper))
}
}
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val expected = mapOf(userWalletId to setOf(wrapper.toDomain()))
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
}
}

View file

@ -0,0 +1,188 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `refresh the single id if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the single id if runtime store contains balance with this id`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance().toDomain(source = StatusSource.ACTUAL)
runtimeStore.store(
value = mapOf(userWalletId to setOf(balance)),
)
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
balance.copySealed(source = StatusSource.CACHE),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the multi ids if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val runtimeExpected = mapOf(
userWalletId to stakingIds.mapTo(hashSetOf()) {
YieldBalance.Error(integrationId = it.integrationId, address = it.address)
},
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the multi ids if runtime store contains balance with this id`() = runTest {
val firstBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.first())
.toDomain(source = StatusSource.ACTUAL)
val secondBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.last())
.toDomain(source = StatusSource.ACTUAL)
runtimeStore.store(
value = mapOf(userWalletId to setOf(firstBalance, secondBalance)),
)
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val runtimeExpected = mapOf(
userWalletId to setOf(
firstBalance.copySealed(source = StatusSource.CACHE),
secondBalance.copySealed(source = StatusSource.CACHE),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `store actual if runtime and cache stores contain balance with this id`() = runTest {
val prevWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
runtimeStore.store(
value = mapOf(userWalletId to setOf(prevWrapper.toDomain(source = StatusSource.CACHE))),
)
persistenceStore.updateData {
it.toMutableMap().apply {
put(userWalletId.stringValue, setOf(prevWrapper))
}
}
val newWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
store.storeActual(userWalletId, setOf(newWrapper))
val runtimeExpected = mapOf(
userWalletId to setOf(newWrapper.toDomain(source = StatusSource.ACTUAL)),
)
val persistenceExpected = mapOf(
userWalletId.stringValue to setOf(newWrapper),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
}
@Test
fun `store error if runtime store is empty`() = runTest {
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `store error if runtime store contains balance with this id`() = runTest {
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId)
runtimeStore.store(
value = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE)),
),
)
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
val stakingId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val stakingIds = setOf(
stakingId,
YieldsBalancesStore.StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
),
)
}
}

View file

@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Adapter to replace unsupported currencies for quotes request if it necessary
*/
internal class QuotesUnsupportedCurrenciesIdAdapter {
class QuotesUnsupportedCurrenciesIdAdapter {
/**
* Replaces unsupported currencies id to it replacements for request

View file

@ -14,14 +14,14 @@ import com.tangem.blockchain.blockchains.ton.TonTransactionExtras
import com.tangem.blockchain.blockchains.tron.TronTransactionExtras
import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.models.TransactionType
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -29,7 +29,6 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
import java.math.BigInteger
import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType
internal class DefaultTransactionRepository(
private val walletManagersFacade: WalletManagersFacade,
@ -45,7 +44,6 @@ internal class DefaultTransactionRepository(
userWalletId: UserWalletId,
network: Network,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
@ -54,14 +52,50 @@ internal class DefaultTransactionRepository(
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found")
return@withContext walletManager.createTransactionDataInternal(
return@withContext walletManager.createTransaction(
amount = amount,
fee = fee,
memo = memo,
destination = destination,
).copy(
extras = txExtras ?: getMemoExtras(networkId = network.id.value, memo),
)
}
override suspend fun createTransferTransaction(
amount: Amount,
fee: Fee,
memo: String?,
destination: String,
userWalletId: UserWalletId,
network: Network,
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val callData = SmartContractCallDataProviderFactory.getTokenTransferCallData(
destinationAddress = destination,
amount = amount,
blockchain = blockchain,
)
val extras = if (amount.type is AmountType.Token && callData != null) {
createTransactionDataExtras(
callData = callData,
network = network,
nonce = null,
gasLimit = null,
)
} else {
null
}
return@withContext createTransaction(
amount = amount,
fee = fee,
memo = null,
destination = destination,
userWalletId = userWalletId,
network = network,
txExtras = txExtras,
hash = hash,
txExtras = getMemoExtras(networkId = network.id.value, memo = memo) ?: extras,
)
}
@ -73,25 +107,16 @@ internal class DefaultTransactionRepository(
spenderAddress: String,
userWalletId: UserWalletId,
network: Network,
hash: String?,
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found")
val approver = walletManager as? Approver ?: error("Cannot cast to Approver")
val approvalData = approver.getApproveData(
spenderAddress = spenderAddress,
value = approvalAmount,
)
val extras = createTransactionDataExtras(
data = approvalData,
callData = SmartContractCallDataProviderFactory.getApprovalCallData(
spenderAddress = spenderAddress,
amount = approvalAmount,
blockchain = blockchain,
),
network = network,
transactionType = TransactionType.APPROVE,
nonce = null,
gasLimit = null,
)
@ -104,7 +129,6 @@ internal class DefaultTransactionRepository(
userWalletId = userWalletId,
network = network,
txExtras = extras,
hash = hash,
)
}
@ -115,9 +139,6 @@ internal class DefaultTransactionRepository(
destination: String,
userWalletId: UserWalletId,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): Result<Unit> = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersStore.getSyncOrNull(
@ -129,14 +150,12 @@ internal class DefaultTransactionRepository(
val validator = walletManager as? TransactionValidator
if (validator != null) {
val transactionData = walletManager.createTransactionDataInternal(
val transactionData = walletManager.createTransaction(
amount = amount,
fee = fee ?: Fee.Common(amount = amount),
memo = memo,
destination = destination,
network = network,
txExtras = txExtras,
hash = hash,
).copy(
extras = getMemoExtras(networkId = network.id.value, memo = memo),
)
validator.validate(transactionData = transactionData)
@ -178,9 +197,8 @@ internal class DefaultTransactionRepository(
}
override fun createTransactionDataExtras(
data: String,
callData: SmartContractCallData,
network: Network,
transactionType: TransactionType,
nonce: BigInteger?,
gasLimit: BigInteger?,
): TransactionExtras {
@ -189,15 +207,14 @@ internal class DefaultTransactionRepository(
return when {
blockchain.isEvm() -> {
EthereumTransactionExtras(
data = data.hexToBytes(),
callData = callData,
gasLimit = gasLimit,
nonce = nonce,
)
}
blockchain == Blockchain.Tron -> {
TronTransactionExtras(
data = data.hexToBytes(),
txType = convertToSdkTransactionType(transactionType),
callData = callData,
)
}
else -> error("Data extras not supported for $blockchain")
@ -226,33 +243,6 @@ internal class DefaultTransactionRepository(
)
}
private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType {
return when (transactionType) {
TransactionType.APPROVE -> SdkTransactionType.APPROVE
}
}
@Suppress("LongParameterList")
private fun WalletManager.createTransactionDataInternal(
amount: Amount,
fee: Fee,
memo: String?,
destination: String,
network: Network,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData.Uncompiled {
if (txExtras != null && memo != null) {
// throw error for now to avoid programmers errors when use extras
error("Both txExtras and memo provided, use only one of them")
}
val extras = txExtras ?: getMemoExtras(network.id.value, memo)
return createTransaction(amount, fee, destination).copy(
hash = hash,
extras = extras,
)
}
@Suppress("CyclomaticComplexMethod")
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)
@ -280,4 +270,27 @@ internal class DefaultTransactionRepository(
else -> null
}
}
override suspend fun prepareForSend(
transactionData: TransactionData,
signer: TransactionSigner,
userWalletId: UserWalletId,
network: Network,
): Result<ByteArray> = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
val preparer = walletManager as? TransactionPreparer ?: kotlin.run {
Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder")
error("Wallet manager does not support TransactionPreparer")
}
when (val prepareForSend = preparer.prepareForSend(transactionData, signer)) {
is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error)
is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data)
}
}
}

View file

@ -1,15 +1,60 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VisaActivationStatusConverter @Inject constructor() :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
private val lastUpdatedAt = MutableStateFlow<String?>(null)
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// handle pin code error
// either we entered pin code before with an error (WasError) or after receiving an error (InProgress)
if (
value.status == Status.AwaitingPin.stringValue &&
value.stepChangeCode != null &&
value.stepChangeCode == PIN_CODE_VALIDATION_ERROR
) {
return if (lastUpdatedAt.value != value.updatedAt) {
lastUpdatedAt.value == value.updatedAt
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
)
} else {
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
)
}
}
// TODO Will be implemented in the future
return VisaActivationRemoteState.Activated
}
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
return VisaActivationOrderInfo(
orderId = id,
customerId = customerId,
customerWalletAddress = customerWalletAddress,
)
}
private enum class Status(val stringValue: String) {
AwaitingPin("AWAITING_PIN"),
// TODO complete statuses list when backend is ready
}
private companion object {
const val PIN_CODE_VALIDATION_ERROR = 1000
}
}

View file

@ -14,6 +14,8 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.transaction)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens)
@ -35,6 +37,7 @@ dependencies {
/* Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/* Reown - WalletConnect */
implementation(deps.reownCore) {
@ -47,4 +50,11 @@ dependencies {
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/* Tests */
testImplementation(projects.common.test)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
}

View file

@ -5,27 +5,25 @@ import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.request.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -58,12 +56,12 @@ internal object WalletConnectDataModule {
application: Application,
sessionsManager: DefaultWcSessionsManager,
networkService: DefaultWcRequestService,
wcPairFlow: DefaultWcPairUseCase,
pairSdkDelegate: WcPairSdkDelegate,
): WcInitializeUseCase = DefaultWcInitializeUseCase(
application = application,
sessionsManager = sessionsManager,
networkService = networkService,
wcPairFlow = wcPairFlow,
pairSdkDelegate = pairSdkDelegate,
)
@Provides
@ -72,16 +70,22 @@ internal object WalletConnectDataModule {
sessionsManager: WcSessionsManager,
associateNetworksDelegate: AssociateNetworksDelegate,
caipNamespaceDelegate: CaipNamespaceDelegate,
sdkDelegate: WcPairSdkDelegate,
): DefaultWcPairUseCase = DefaultWcPairUseCase(
sessionsManager = sessionsManager,
associateNetworksDelegate = associateNetworksDelegate,
caipNamespaceDelegate = caipNamespaceDelegate,
sdkDelegate = sdkDelegate,
)
@Provides
@Singleton
fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default
@Provides
@Singleton
fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate()
@Provides
@Singleton
fun defaultWcSessionsManager(
@ -107,38 +111,39 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun defaultWcRequestService(
sessionsManager: WcSessionsManager,
respondService: WcRespondService,
dispatchers: CoroutineDispatcherProvider,
diHelperBox: DiHelperBox,
): DefaultWcRequestService {
val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
return DefaultWcRequestService(
sessionsManager = sessionsManager,
respondService = respondService,
requestAdapters = diHelperBox.handlers,
requestConverters = diHelperBox.handlers,
scope = scope,
)
}
@Provides
@Singleton
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
@Provides
@Singleton
fun wcRespondService(): WcRespondService = DefaultWcRespondService()
@Provides
@Singleton
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
fun wcEthNetwork(
@SdkMoshi moshi: Moshi,
excludedBlockchains: ExcludedBlockchains,
sessionsManager: WcSessionsManager,
factories: WcEthNetwork.Factories,
): WcEthNetwork = WcEthNetwork(
moshi = moshi,
respondService = respondService,
excludedBlockchains = excludedBlockchains,
sessionsManager = sessionsManager,
factories = factories,
)
@Provides
@Singleton
fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork()
fun wcSolanaNetwork(excludedBlockchains: ExcludedBlockchains): WcSolanaNetwork = WcSolanaNetwork(
excludedBlockchains = excludedBlockchains,
)
@Provides
@Singleton
@ -156,12 +161,10 @@ internal object WalletConnectDataModule {
diHelperBox: DiHelperBox,
getWallets: GetWalletsUseCase,
currenciesRepository: CurrenciesRepository,
excludedBlockchains: ExcludedBlockchains,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = diHelperBox.converters,
getWallets = getWallets,
currenciesRepository = currenciesRepository,
excludedBlockchains = excludedBlockchains,
)
@Provides
@ -170,14 +173,14 @@ internal object WalletConnectDataModule {
handlers = setOf(
ethNetwork,
),
converters = buildMap {
ethNetwork.namespaceKey to ethNetwork
solanaNetwork.namespaceKey to solanaNetwork
},
converters = setOf(
ethNetwork,
solanaNetwork,
),
)
internal class DiHelperBox(
val converters: Map<NamespaceKey, WcNamespaceConverter>,
val handlers: Set<WcMethodHandler<WcMethod>>,
val converters: Set<WcNamespaceConverter>,
val handlers: Set<WcRequestToUseCaseConverter>,
)
}

View file

@ -6,7 +6,7 @@ import com.reown.android.CoreClient
import com.reown.android.relay.ConnectionType
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcSdkObserver
@ -17,13 +17,13 @@ internal class DefaultWcInitializeUseCase(
private val application: Application,
private val sessionsManager: DefaultWcSessionsManager,
private val networkService: DefaultWcRequestService,
private val wcPairFlow: DefaultWcPairUseCase,
private val pairSdkDelegate: WcPairSdkDelegate,
) : WcInitializeUseCase {
private val wcSdkObservers = mutableSetOf<WcSdkObserver>(
sessionsManager,
networkService,
wcPairFlow,
pairSdkDelegate,
)
override fun init(projectId: String) {

View file

@ -0,0 +1,102 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.extensions.isAscii
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.OnSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
internal class DefaultWcEthMessageSignUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted private val method: WcEthMethod.MessageSign,
private val walletManagersFacade: WalletManagersFacade,
private val signUseCase: SignUseCase,
) : BaseWcSignUseCase<Nothing, WcEthMessageSignUseCase.SignModel>(),
WcEthMessageSignUseCase {
override val onSign: OnSign<WcEthMessageSignUseCase.SignModel> = collector@{ state ->
val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg)
val userWallet = session.wallet
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: return@collector
val signedHash = signUseCase(hashToSign, userWallet, network)
.onLeft { emit(state.toResult(it.left())) }
.getOrNull() ?: return@collector
val respond = EthereumUtils.prepareSignedMessageData(
signedHash = signedHash,
hashToSign = hashToSign,
publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(),
)
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(wcRespondResult))
}
override fun invoke(): Flow<WcSignState<WcEthMessageSignUseCase.SignModel>> = flow {
val model = WcEthMessageSignUseCase.SignModel(
rawMsg = method.message,
account = method.account,
humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(),
)
emitAll(delegate(model))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): DefaultWcEthMessageSignUseCase
}
}
object LegacySdkHelper {
private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
fun createMessageData(message: String): ByteArray {
val messageData = try {
message.removePrefix(HEX_PREFIX).hexToBytes()
} catch (exception: Exception) {
message.asciiToHex()?.hexToBytes() ?: byteArrayOf()
}
val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray()
return (prefixData + messageData).toKeccak()
}
fun hexToAscii(hex: String): String? {
return try {
hex.removePrefix(HEX_PREFIX).hexToBytes().map {
val char = it.toInt().toChar()
if (char.isAscii()) char else return null
}.joinToString("")
} catch (exception: Exception) {
return null
}
}
private fun String.asciiToHex(): String? {
return map {
if (!it.isAscii()) return null
Integer.toHexString(it.code)
}.joinToString("")
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.OnSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSendTransactionUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class DefaultWcEthSendTransactionUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted private val method: WcEthMethod.SendTransaction,
override val respondService: WcRespondService,
private val sendTransaction: SendTransactionUseCase,
) : BaseWcSignUseCase<Nothing, TransactionData>(),
WcEthSendTransactionUseCase {
override val onSign: OnSign<TransactionData> = collector@{ state ->
val hash = sendTransaction(state.signModel, wallet, network)
.onLeft { error ->
val sendError = IllegalArgumentException(error.toString()) // todo(wc) use domain error
emit(state.toResult(sendError.left()))
}
.getOrNull() ?: return@collector
val respondHash = if (hash.startsWith(HEX_PREFIX)) hash else HEX_PREFIX + hash
val respondResult = respondService.respond(rawSdkRequest, respondHash)
emit(state.toResult(respondResult))
}
override fun updateFee(fee: TransactionFee) {
TODO("Not yet implemented")
}
override fun invoke(): Flow<WcSignState<TransactionData>> {
method
TODO("Not yet implemented")
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcEthMethod.SendTransaction,
): DefaultWcEthSendTransactionUseCase
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.OnSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSignTransactionUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class DefaultWcEthSignTransactionUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
private val prepareForSend: PrepareForSendUseCase,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted private val method: WcEthMethod.SignTransaction,
) : BaseWcSignUseCase<Nothing, TransactionData>(),
WcEthSignTransactionUseCase {
override val onSign: OnSign<TransactionData> = collector@{ state ->
val hash = prepareForSend(state.signModel, wallet, network)
.onLeft { error ->
emit(state.toResult(error.left()))
}
.getOrNull() ?: return@collector
val hashString = hash.toHexString()
val respondHash = if (hashString.startsWith(HEX_PREFIX)) hashString else HEX_PREFIX + hashString
val respondResult = respondService.respond(rawSdkRequest, respondHash)
emit(state.toResult(respondResult))
}
override fun updateFee(fee: TransactionFee) {
TODO("Not yet implemented")
}
override fun invoke(): Flow<WcSignState<TransactionData>> {
method
TODO("Not yet implemented")
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcEthMethod.SignTransaction,
): DefaultWcEthSignTransactionUseCase
}
}

View file

@ -2,57 +2,76 @@ package com.tangem.data.walletconnect.network.ethereum
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.WcUseCase
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
import com.tangem.domain.wallets.models.UserWallet
import jakarta.inject.Inject
internal class WcEthNetwork(
internal class WcEthNetwork constructor(
private val moshi: Moshi,
private val respondService: WcRespondService,
) : WcMethodHandler<WcEthMethod>, WcUseCasesFlowProvider, WcNamespaceConverter {
private val _useCases: Channel<WcUseCase> = Channel(Channel.BUFFERED)
override val useCases = _useCases.receiveAsFlow()
private val excludedBlockchains: ExcludedBlockchains,
private val sessionsManager: WcSessionsManager,
private val factories: Factories,
) : WcRequestToUseCaseConverter, WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey("eip155")
override fun canHandle(methodName: String): Boolean {
return Name.entries.find { it.raw == methodName } != null
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
val methodKey = request.request.method
val name = Name.entries.find { it.raw == methodKey } ?: return null
val method: WcEthMethod = name.toMethod(request) ?: return null
val session = sessionsManager.findSessionByTopic(request.topic) ?: return null
val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null
val context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network)
return when (method) {
is WcEthMethod.MessageSign -> factories.messageSign.create(context, method)
is WcEthMethod.SendTransaction -> factories.sendTransaction.create(context, method)
is WcEthMethod.SignTransaction -> factories.signTransaction.create(context, method)
}
}
override fun deserialize(methodName: String, params: String): WcEthMethod? {
val name = Name.entries.find { it.raw == methodName } ?: return null
return when (name) {
Name.Sign -> TODO()
Name.PersonalSign -> WcMethodHandler.fromJson<SignMessage>(params, moshi)
private fun Name.toMethod(request: WcSdkSessionRequest): WcEthMethod? {
val rawParams = request.request.params
return when (this) {
Name.EthSign,
Name.PersonalSign,
-> moshi.fromJson<List<String>>(rawParams)?.let { list ->
val accountIndex = if (this == Name.EthSign) 0 else 1
val messageIndex = if (this == Name.EthSign) 1 else 0
val account = list.getOrNull(accountIndex) ?: return@let null
val message = list.getOrNull(messageIndex) ?: return@let null
WcEthMethod.MessageSign(account = account, message = message)
}
Name.SignTypeData -> TODO()
Name.SignTypeDataV4 -> TODO()
Name.SignTransaction -> TODO()
Name.SendTransaction -> TODO()
Name.SignTransaction,
Name.SendTransaction,
-> moshi.fromJson<List<WcEthTransactionParams>>(rawParams)
?.firstOrNull()
?.let {
if (this == Name.SignTransaction) {
WcEthMethod.SignTransaction(transaction = it)
} else {
WcEthMethod.SendTransaction(transaction = it)
}
}
}
}
override fun handle(wcRequest: WcRequest<WcMethod>) {
wcRequest as WcRequest<WcEthMethod>
val useCase = when (wcRequest.method) {
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
}
_useCases.trySend(useCase)
}
enum class Name(val raw: String) {
Sign("eth_sign"),
EthSign("eth_sign"),
PersonalSign("personal_sign"),
SignTypeData("eth_signTypedData"),
SignTypeDataV4("eth_signTypedData_v4"),
@ -60,13 +79,18 @@ internal class WcEthNetwork(
SendTransaction("eth_sendTransaction"),
}
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
return toNetwork(chainId, wallet, excludedBlockchains)
}
override fun toBlockchain(chainId: CAIP2): Blockchain? {
if (chainId.namespace != namespaceKey.key) return null
val ethChainId = chainId.reference.toIntOrNull() ?: return null
return Blockchain.fromChainId(ethChainId)
}
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
override fun toCAIP2(network: Network): CAIP2? {
val blockchain = Blockchain.fromId(network.id.value)
if (!blockchain.isEvm()) return null
val chainId = blockchain.getChainId() ?: return null
return CAIP2(
@ -74,4 +98,10 @@ internal class WcEthNetwork(
reference = chainId.toString(),
)
}
internal class Factories @Inject constructor(
val messageSign: DefaultWcEthMessageSignUseCase.Factory,
val sendTransaction: DefaultWcEthSendTransactionUseCase.Factory,
val signTransaction: DefaultWcEthSignTransactionUseCase.Factory,
)
}

View file

@ -1,11 +1,17 @@
package com.tangem.data.walletconnect.network.solana
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
internal class WcSolanaNetwork constructor(
private val excludedBlockchains: ExcludedBlockchains,
) : WcNamespaceConverter {
internal class WcSolanaNetwork : WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey("solana")
override fun toBlockchain(chainId: CAIP2): Blockchain? {
@ -17,7 +23,12 @@ internal class WcSolanaNetwork : WcNamespaceConverter {
}
}
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
return toNetwork(chainId, wallet, excludedBlockchains)
}
override fun toCAIP2(network: Network): CAIP2? {
val blockchain = Blockchain.fromId(network.id.value)
val chainId = when (blockchain) {
Blockchain.Solana -> MAINNET_CHAIN_ID
Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID

View file

@ -1,92 +1,72 @@
package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcNetwork
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
internal class AssociateNetworksDelegate constructor(
private val namespaceConverters: Map<NamespaceKey, WcNamespaceConverter>,
private val namespaceConverters: Set<WcNamespaceConverter>,
private val getWallets: GetWalletsUseCase,
private val currenciesRepository: CurrenciesRepository,
private val excludedBlockchains: ExcludedBlockchains,
) {
@Throws(WcPairError.UnsupportedNetworks::class)
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWalletId, ProposalNetwork> {
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWallet, ProposalNetwork> {
val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency }
val requiredNamespaces: Set<CAIP2> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<CAIP2> = sessionProposal.optionalNamespaces.setOfChainId()
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
return userWallets.associate { wallet ->
wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces)
}
return userWallets
.associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) }
}
private suspend fun mapNetworksForWallet(
wallet: UserWallet,
requiredNamespaces: Set<CAIP2>,
optionalNamespaces: Set<CAIP2>,
requiredNamespaces: Set<String>,
optionalNamespaces: Set<String>,
): ProposalNetwork {
val walletNetworks = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(wallet.walletId)
.filterIsInstance<CryptoCurrency.Coin>()
.map { it.network }
val unknownRequired = mutableSetOf<WcNetwork.Unknown>()
val missingRequired = mutableSetOf<WcNetwork.Supported>()
val required = mutableSetOf<WcNetwork.Supported>()
val available = mutableSetOf<WcNetwork.Supported>()
val notAdded = mutableSetOf<WcNetwork.Supported>()
fun CAIP2.toBlockchain() = namespaceConverters[NamespaceKey(this.namespace)]?.toBlockchain(this)
fun Blockchain.toNetwork() = getNetwork(
blockchain = this,
extraDerivationPath = null,
scanResponse = wallet.scanResponse,
excludedBlockchains = excludedBlockchains,
)
val unknownRequired = mutableSetOf<String>()
val missingRequired = mutableSetOf<Network>()
val required = mutableSetOf<Network>()
val available = mutableSetOf<Network>()
val notAdded = mutableSetOf<Network>()
requiredNamespaces.forEach { chainId ->
val blockchain = chainId.toBlockchain()
if (blockchain == null) {
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId)))
return@forEach
}
val wcNetwork = blockchain.toNetwork()
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
if (wcNetwork == null) {
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain)))
unknownRequired.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork == null) {
missingRequired.add(WcNetwork.Supported(wcNetwork))
missingRequired.add(wcNetwork)
} else {
required.add(WcNetwork.Supported(walletNetwork))
required.add(walletNetwork)
}
}
optionalNamespaces.forEach { chainId ->
val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
?: return@forEach
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork == null) {
available.add(WcNetwork.Supported(wcNetwork))
available.add(wcNetwork)
} else {
notAdded.add(WcNetwork.Supported(walletNetwork))
notAdded.add(walletNetwork)
}
}
if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired)
return ProposalNetwork(
walletId = wallet.walletId,
wallet = wallet,
missingRequired = missingRequired,
required = required,
available = available,
@ -94,10 +74,8 @@ internal class AssociateNetworksDelegate constructor(
)
}
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<CAIP2> =
this.values.flatMap { proposal -> proposal.chains ?: listOf() }
.mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet()
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<String> =
this.values.flatMap { proposal -> proposal.chains ?: listOf() }.toSet()
private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase)
private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName()
private fun missingNetworkName(chainId: String): String = chainId.replaceFirstChar(Char::titlecase)
}

View file

@ -1,32 +1,27 @@
package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.walletconnect.model.CAIP10
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWallet
internal class CaipNamespaceDelegate constructor(
private val namespaceConverters: Map<NamespaceKey, WcNamespaceConverter>,
private val namespaceConverters: Set<WcNamespaceConverter>,
private val walletManagersFacade: WalletManagersFacade,
) {
suspend fun associate(
sessionProposal: Wallet.Model.SessionProposal,
userWalletId: UserWalletId,
userWallet: UserWallet,
networks: List<Network>,
): Map<String, Wallet.Model.Namespace.Session> {
val converters = namespaceConverters.values
val result = mutableMapOf<String, Session>()
networks.map { network ->
val blockchain = Blockchain.fromId(network.id.value)
val address = walletManagersFacade.getDefaultAddress(userWalletId, network)
val chainId = converters.firstOrNull { it.toCAIP2(blockchain) != null }?.toCAIP2(blockchain)
val address = walletManagersFacade.getDefaultAddress(userWallet.walletId, network)
val chainId = namespaceConverters.firstNotNullOfOrNull { it.toCAIP2(network) }
requireNotNull(chainId)
requireNotNull(address)
CAIP10(chainId = chainId, accountAddress = address)

View file

@ -4,8 +4,6 @@ import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSession
@ -15,15 +13,13 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import kotlin.coroutines.resume
val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox")
@ -31,27 +27,18 @@ internal class DefaultWcPairUseCase(
private val sessionsManager: WcSessionsManager,
private val associateNetworksDelegate: AssociateNetworksDelegate,
private val caipNamespaceDelegate: CaipNamespaceDelegate,
) : WcPairUseCase, WcSdkObserver {
private val sdkDelegate: WcPairSdkDelegate,
) : WcPairUseCase {
private val onCallTerminalAction = Channel<TerminalAction>()
private val onSessionProposal =
Channel<Pair<Wallet.Model.SessionProposal, Wallet.Model.VerifyContext>>(Channel.BUFFERED)
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>(Channel.BUFFERED)
override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow<WcPairState> {
return flow {
emit(WcPairState.Loading)
// call sdk.pair and wait result, finish flow on error
walletKitPair(uri).onLeft { throwable ->
emit(WcPairState.Error(WcPairError.Unknown(throwable.localizedMessage.orEmpty())))
return@flow
}
// wait for sdk onSessionProposal callback
val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow()
.first { (sessionProposal, verifyContext) ->
true // todo(wc) check verifyContext? compare uri?
}
val sdkSessionProposal = sdkDelegate.pair(uri)
.onLeft { emit(WcPairState.Error(it)) }
.getOrNull() ?: return@flow
// check unsupported dApps, just local constant for now, finish if unsupported
if (sdkSessionProposal.name in unsupportedDApps) {
@ -62,44 +49,32 @@ internal class DefaultWcPairUseCase(
}
val proposalState = buildProposalState(sdkSessionProposal)
.fold(ifLeft = { WcPairState.Error(it) }, ifRight = { it })
.onLeft { emit(WcPairState.Error(it)) }
.getOrNull() ?: return@flow
emit(proposalState)
// wait first terminal action and continue WC pair flow
val terminalAction = onCallTerminalAction.receiveAsFlow().first()
val sessionForApprove: WcSessionApprove? = when (terminalAction) {
is TerminalAction.Approve -> terminalAction.sessionForApprove
TerminalAction.Reject -> {
// non suspending WalletKit.rejectSession call
rejectSession(sdkSessionProposal.proposerPublicKey)
null
}
TerminalAction.Reject -> null
}
// finish flow if rejected above
sessionForApprove ?: return@flow
if (sessionForApprove == null) {
sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey)
return@flow
}
// start flow of approving in wc sdk
emit(WcPairState.Approving.Loading(sessionForApprove))
// call sdk approve and wait result
val either = walletKitApproveSession(
sessionForApprove = sessionForApprove,
sdkSessionProposal = sdkSessionProposal,
).fold(
ifLeft = { WcPairError.ExternalApprovalError(it.localizedMessage.orEmpty()).left() },
ifRight = {
when (val settledSession = onSessionSettleResponse.receiveAsFlow().first()) {
is Wallet.Model.SettledSessionResponse.Error -> WcPairError.ExternalApprovalError(
settledSession.errorMessage,
).left()
is Wallet.Model.SettledSessionResponse.Result -> {
val newSession = settledSession.session.toDomain(sessionForApprove.walletId)
sessionsManager.saveSession(sessionForApprove.walletId, newSession)
newSession.right()
}
}
},
)
).map { settledSession ->
val newSession = settledSession.session.toDomain(sessionForApprove.wallet)
sessionsManager.saveSession(newSession)
newSession
}
emit(WcPairState.Approving.Result(sessionForApprove, either))
}
}
@ -112,77 +87,20 @@ internal class DefaultWcPairUseCase(
onCallTerminalAction.trySend(TerminalAction.Reject)
}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
// Triggered when wallet receives the session proposal sent by a Dapp
Timber.i("sessionProposal: $sessionProposal")
onSessionProposal.trySend(sessionProposal to verifyContext)
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
// Triggered when wallet receives the session settlement response from Dapp
Timber.i("onSessionSettleResponse: $settleSessionResponse")
onSessionSettleResponse.trySend(settleSessionResponse)
}
private suspend fun walletKitPair(uri: String): Either<Throwable, Unit> =
suspendCancellableCoroutine { continuation ->
WalletKit.pair(
params = Wallet.Params.Pair(uri),
onSuccess = {
Timber.i("Paired successfully: $it")
continuation.resume(Unit.right())
},
onError = {
Timber.e("Error while pairing: $it")
continuation.resume(it.throwable.left())
},
)
}
private suspend fun walletKitApproveSession(
sessionForApprove: WcSessionApprove,
sdkSessionProposal: Wallet.Model.SessionProposal,
): Either<Throwable, Unit> {
): Either<WcPairError, Wallet.Model.SettledSessionResponse.Result> {
val namespaces = caipNamespaceDelegate.associate(
sdkSessionProposal,
sessionForApprove.walletId,
sessionForApprove.network.map { it.network },
sessionForApprove.wallet,
sessionForApprove.network,
)
val sessionApprove = Wallet.Params.SessionApprove(
proposerPublicKey = sdkSessionProposal.proposerPublicKey,
namespaces = namespaces,
)
return suspendCancellableCoroutine { continuation ->
WalletKit.approveSession(
params = sessionApprove,
onSuccess = {
Timber.i("Approved successfully: $it")
continuation.resume(Unit.right())
},
onError = {
Timber.e("Error while approving: $it")
continuation.resume(it.throwable.left())
},
)
}
}
private fun rejectSession(proposerPublicKey: String) {
WalletKit.rejectSession(
params = Wallet.Params.SessionReject(
proposerPublicKey = proposerPublicKey,
reason = "",
),
onSuccess = {
Timber.i("Rejected successfully: $it")
},
onError = {
Timber.e("Error while rejecting: $it")
},
)
return sdkDelegate.approve(sessionApprove)
}
private suspend fun buildProposalState(
@ -209,8 +127,8 @@ internal class DefaultWcPairUseCase(
}
},)
private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession(
userWalletId = walletId,
private fun Wallet.Model.Session.toDomain(wallet: UserWallet): WcSession = WcSession(
wallet = wallet,
sdkModel = WcSdkSessionConverter.convert(this),
)

View file

@ -0,0 +1,98 @@
package com.tangem.data.walletconnect.pair
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.model.WcPairError
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class WcPairSdkDelegate : WcSdkObserver {
private val onSessionProposal = Channel<Wallet.Model.SessionProposal>()
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>()
suspend fun pair(url: String): Either<WcPairError, Wallet.Model.SessionProposal> = coroutineScope {
suspend fun proposalCallback() = onSessionProposal
.receiveAsFlow()
.filter { proposal -> proposal.url == url }
.first()
val pairCall = async { sdkPair(url) }
val proposal = async { proposalCallback() }
pairCall.await().map { proposal.await() }
}
suspend fun approve(
sessionApprove: Wallet.Params.SessionApprove,
): Either<WcPairError, Wallet.Model.SettledSessionResponse.Result> = coroutineScope {
suspend fun approveCallback() = onSessionSettleResponse
.receiveAsFlow()
.first()
val approveCall = async { sdkApprove(sessionApprove) }
val approveCallback = async { approveCallback() }
approveCall.await()
.onLeft { return@coroutineScope it.left() }
when (val result = approveCallback.await()) {
is Wallet.Model.SettledSessionResponse.Result -> result.right()
is Wallet.Model.SettledSessionResponse.Error ->
WcPairError.ExternalApprovalError(result.errorMessage).left()
}
}
fun rejectSession(proposerPublicKey: String) {
WalletKit.rejectSession(
params = Wallet.Params.SessionReject(
proposerPublicKey = proposerPublicKey,
reason = "",
),
onSuccess = {},
onError = {},
)
}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
// Triggered when wallet receives the session proposal sent by a Dapp
onSessionProposal.trySend(sessionProposal)
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
// Triggered when wallet receives the session settlement response from Dapp
onSessionSettleResponse.trySend(settleSessionResponse)
}
private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either<WcPairError, Unit> {
return suspendCancellableCoroutine { continuation ->
WalletKit.approveSession(
params = sessionApprove,
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.toPairError()) },
)
}
}
private suspend fun sdkPair(uri: String): Either<WcPairError, Unit> {
return suspendCancellableCoroutine { continuation ->
WalletKit.pair(
params = Wallet.Params.Pair(uri),
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.toPairError()) },
)
}
}
private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left()
}

View file

@ -3,23 +3,20 @@ package com.tangem.data.walletconnect.request
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.request.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
internal class DefaultWcRequestService(
private val sessionsManager: WcSessionsManager,
private val respondService: WcRespondService,
private val requestAdapters: Set<WcMethodHandler<WcMethod>>,
private val requestConverters: Set<WcRequestToUseCaseConverter>,
private val scope: CoroutineScope,
) : WcRequestService, WcSdkObserver {
) : WcSdkObserver, WcUseCasesFlowProvider {
override val requests: MutableSharedFlow<WcRequest<*>> = MutableSharedFlow()
private val _useCases: Channel<WcMethodUseCase> = Channel(Channel.BUFFERED)
override val useCases = _useCases.receiveAsFlow()
override fun onSessionRequest(
sessionRequest: Wallet.Model.SessionRequest,
@ -27,21 +24,10 @@ internal class DefaultWcRequestService(
) {
// Triggered when a Dapp sends SessionRequest to sign a transaction or a message
val sr = WcSdkSessionRequestConverter.convert(sessionRequest)
val method = sr.request.method
val params = sr.request.params
scope.launch {
val session = sessionsManager.findSessionByTopic(sr.topic)
val handler: WcMethodHandler<WcMethod>? = requestAdapters.firstOrNull { it.canHandle(method) }
val deserialized: WcMethod? = handler?.deserialize(method, params)
if (handler == null || deserialized == null || session == null) {
respondService.rejectRequest(sr, "UnsupportedMethod") // todo(wc) use our domain error
return@launch
}
val wcRequest = WcRequest(sr, session, deserialized)
handler.handle(wcRequest)
requests.emit(wcRequest)
val useCase = requestConverters.firstNotNullOfOrNull { it.toUseCase(sr) }
?: return@launch // todo(wc) handle unsupported
_useCases.trySend(useCase)
}
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.data.walletconnect.request
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
interface WcMethodHandler<out M : WcMethod> {
fun canHandle(methodName: String): Boolean
fun deserialize(methodName: String, params: String): M?
fun handle(wcRequest: WcRequest<WcMethod>)
companion object {
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified T> fromJson(params: String, moshi: Moshi): T? =
runCatching { moshi.adapter<T>().fromJsonValue(params) }.getOrNull()
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.data.walletconnect.request
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
interface WcRequestToUseCaseConverter {
suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase?
companion object {
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified T> Moshi.fromJson(params: String): T? =
runCatching { this.adapter<T>().fromJson(params) }.getOrNull()
}
}

View file

@ -6,7 +6,6 @@ import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.respond.WcRespondService
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService {
},
)
}
override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) {
WalletKit.respondSessionRequest(
params = Wallet.Params.SessionRequestResponse(
sessionTopic = request.topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
id = request.request.id,
code = 0,
message = message,
),
),
onSuccess = {},
onError = {},
)
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.walletconnect.respond
import arrow.core.Either
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
interface WcRespondService {
suspend fun respond(request: WcSdkSessionRequest, response: String): Either<Throwable, Unit>
suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either<Throwable, Unit>
fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "")
}

View file

@ -12,7 +12,7 @@ import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
@ -36,20 +36,22 @@ internal class DefaultWcSessionsManager constructor(
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
private val oneTimeMigration = MutableStateFlow(true)
override val sessions: Flow<Map<UserWalletId, List<WcSession>>>
get() = store.sessions
.transform { inStore ->
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
.transform { pair ->
val (wallets, inStore) = pair
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
if (oneTimeMigration.value) {
oneTimeMigration.value = false
val someMigrated = migrateLegacyStore(inStore)
val someMigrated = migrateLegacyStore(inStore, inSdk, wallets)
if (someMigrated) return@transform // ignore emit, wait next one
}
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
val associatedSessions: List<WcSession> = associateWithSdk(inSdk, inStore)
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
val someRemove = removeUnknownSessions(inStore, associatedSessions)
if (someRemove) return@transform // ignore emit, wait next one
emit(associatedSessions.groupBy { it.userWalletId })
emit(associatedSessions.groupBy { it.wallet })
}
.distinctUntilChanged()
.flowOn(dispatchers.io)
override fun onWcSdkInit() {
@ -57,11 +59,11 @@ internal class DefaultWcSessionsManager constructor(
listenOnSessionDelete()
}
override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) {
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId))
override suspend fun saveSession(session: WcSession) {
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId))
}
override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either<Throwable, Unit> {
override suspend fun removeSession(session: WcSession): Either<Throwable, Unit> {
val topic = session.sdkModel.topic
val sdkCall = sdkDisconnectSession(topic)
sdkCall.onLeft { return it.left() }
@ -80,9 +82,13 @@ internal class DefaultWcSessionsManager constructor(
}
override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) {
val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null
val storedSessions = sessions.firstOrNull()
?.values?.flatten()
?.firstOrNull { it.sdkModel.topic == topic }
?: return@withContext null
val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
val wallet = storedSessions.wallet
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
}
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
@ -90,28 +96,35 @@ internal class DefaultWcSessionsManager constructor(
onSessionDelete.trySend(sessionDelete)
}
private suspend fun migrateLegacyStore(inNewStoreSessions: Set<WcSessionDTO>): Boolean {
val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId }
val inLegacyStoreSessions = walletIds
private suspend fun migrateLegacyStore(
inNewStore: Set<WcSessionDTO>,
inSdk: List<Wallet.Model.Session>,
wallets: List<UserWallet>,
): Boolean {
val walletIds = wallets.map { wallet -> wallet.walletId }
val inLegacyStore = walletIds
.map { walletId ->
flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) }
}
.merge()
.reduce { accumulator, value -> accumulator.plus(value) }
// migrate only active legacySessions
.filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } }
val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions)
val mustSaveInNewStore = inLegacyStore.subtract(inNewStore)
if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore)
return mustSaveInNewStore.isNotEmpty()
}
private fun associateWithSdk(
sdkSessions: List<Wallet.Model.Session>,
storeSessions: Set<WcSessionDTO>,
private fun associate(
inSdk: List<Wallet.Model.Session>,
inStore: Set<WcSessionDTO>,
wallets: List<UserWallet>,
): List<WcSession> {
val wcSessions = sdkSessions.mapNotNull { sdkSession ->
val storedSessions = storeSessions.find { it.topic == sdkSession.topic }
?: return@mapNotNull null
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
val wcSessions = inStore.mapNotNull { session ->
val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null
val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
}
return wcSessions
}
@ -122,9 +135,6 @@ internal class DefaultWcSessionsManager constructor(
val haveSomeUnknown = unknownStoredSessions.isNotEmpty()
if (haveSomeUnknown) {
unknownStoredSessions.forEach { unknown ->
legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic)
}
store.removeSessions(unknownStoredSessions.toSet())
}
return haveSomeUnknown

View file

@ -0,0 +1,70 @@
package com.tangem.data.walletconnect.sign
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.FlowCollector
internal abstract class BaseWcSignUseCase<MiddleAction, SignModel> :
WcMethodUseCase,
WcSignUseCase,
FinalActionCollector<SignModel>,
MiddleActionCollector<MiddleAction, SignModel> {
abstract val respondService: WcRespondService
abstract val context: WcMethodUseCaseContext
override val network: Network get() = context.network
override val session: WcSession get() = context.session
override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest
val wallet: UserWallet get() = session.wallet
protected val delegate by lazy {
WcSignUseCaseDelegate(
finalActionCollector = this,
middleActionCollector = this,
)
}
override val onCancel: suspend (currentState: WcSignState<SignModel>) -> Unit = {
defaultReject()
}
override fun sign() = delegate.sign()
override fun cancel() = delegate.cancel()
protected fun middleAction(action: MiddleAction) = delegate.middleAction(action)
protected fun defaultReject() {
respondService.rejectRequestNonBlock(rawSdkRequest)
}
}
internal interface MiddleActionCollector<MiddleAction, SignModel> {
val onMiddleAction: OnMiddle<MiddleAction, SignModel> get() = { _, _ -> }
}
internal interface FinalActionCollector<SignModel> {
val onSign: OnSign<SignModel> get() = {}
val onCancel: OnCancel<SignModel> get() = {}
}
internal class WcMethodUseCaseContext(
val session: WcSession,
val rawSdkRequest: WcSdkSessionRequest,
val network: Network,
)
internal typealias OnSign<SignModel> =
suspend FlowCollector<WcSignState<SignModel>>.(state: WcSignState<SignModel>) -> Unit
internal typealias OnCancel<SignModel> =
suspend (currentState: WcSignState<SignModel>) -> Unit
internal typealias OnMiddle<MiddleAction, SignModel> =
suspend FlowCollector<SignModel>.(currentState: WcSignState<SignModel>, middleAction: MiddleAction) -> Unit

View file

@ -0,0 +1,28 @@
package com.tangem.data.walletconnect.sign
import arrow.core.Either
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
object SignStateConverter {
internal fun <M> preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign)
internal fun <M> signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing)
internal fun <M> result(result: Either<Throwable, Unit>, signModel: M) =
WcSignState(signModel, WcSignStep.Result(result))
internal fun <M> WcSignState<M>.toPreSign(signModel: M = this.signModel) = copy(
signModel = signModel,
domainStep = WcSignStep.PreSign,
)
internal fun <M> WcSignState<M>.toSigning(signModel: M = this.signModel) = copy(
domainStep = WcSignStep.Signing,
signModel = signModel,
)
internal fun <M> WcSignState<M>.toResult(result: Either<Throwable, Unit>, signModel: M = this.signModel) = copy(
domainStep = WcSignStep.Result(result),
signModel = signModel,
)
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.walletconnect.sign
import arrow.core.left
import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal class WcSignUseCaseDelegate<MiddleAction, SignModel>(
private val finalActionCollector: FinalActionCollector<SignModel>,
private val middleActionCollector: MiddleActionCollector<MiddleAction, SignModel>,
) : FinalActionCollector<SignModel> by finalActionCollector,
MiddleActionCollector<MiddleAction, SignModel> by middleActionCollector {
private val middleActionsChannel = Channel<MiddleAction>()
private val finalActionsChannel = Channel<Action>()
fun cancel() {
finalActionsChannel.trySend(Action.Cancel)
}
fun sign() {
finalActionsChannel.trySend(Action.Sign)
}
fun middleAction(action: MiddleAction) {
middleActionsChannel.trySend(action)
}
operator fun invoke(initModel: SignModel) = channelFlow {
val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign))
state
.onEach { newState -> channel.send(newState) }
.launchIn(this)
fun listenMiddle() = middleActionsChannel.receiveAsFlow()
.buffer()
.transform { middleActions -> this.onMiddleAction(state.value, middleActions) }
.onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } }
.launchIn(this)
var listenMiddleJob: Job = listenMiddle()
fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) }
.onEach { newState -> state.update { newState } }
.catch { exception ->
val errorResult = state.value.toResult(exception.left())
state.update { errorResult }
}
var signJob: Job? = null
finalActionsChannel.receiveAsFlow()
.transformLatest<Action, Unit> { finalAction ->
when (finalAction) {
Action.Cancel -> {
onCancel.invoke(state.value)
channel.close()
}
Action.Sign -> {
val isSigningNow = signJob?.isActive == true
if (isSigningNow) return@transformLatest
listenMiddleJob.cancel()
signJob = launch {
signFlow().collect()
listenMiddleJob = listenMiddle()
}
}
}
}
.launchIn(this)
/**
* keep flow running to attempt re-signing after an error
* or do something after a successful sign
*/
awaitClose()
}
sealed interface Action {
data object Cancel : Action
data object Sign : Action
}
}

View file

@ -1,12 +1,31 @@
package com.tangem.data.walletconnect.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
internal interface WcNamespaceConverter {
val namespaceKey: NamespaceKey
fun toBlockchain(chainId: CAIP2): Blockchain?
fun toCAIP2(blockchain: Blockchain): CAIP2?
fun toBlockchain(chainId: String): Blockchain? = toCAIP2(chainId)?.let { caip2 -> toBlockchain(caip2) }
fun toCAIP2(network: Network): CAIP2?
fun toCAIP2(chainId: String): CAIP2? = CAIP2.fromRaw(chainId)
fun toNetwork(chainId: String, wallet: UserWallet): Network?
fun toNetwork(chainId: String, wallet: UserWallet, excludedBlockchains: ExcludedBlockchains): Network? {
val blockchain = toBlockchain(chainId) ?: return null
return getNetwork(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = wallet.scanResponse,
excludedBlockchains = excludedBlockchains,
)
}
}

View file

@ -0,0 +1,232 @@
package com.tangem.domain.walletconnect
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import io.mockk.coEvery
import io.mockk.coVerifyOrder
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
internal class DefaultWcPairUseCaseTest {
private val sessionsManager: WcSessionsManager = mockk<WcSessionsManager>()
private val associateNetworksDelegate: AssociateNetworksDelegate = mockk<AssociateNetworksDelegate>()
private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk<CaipNamespaceDelegate>()
private val sdkDelegate: WcPairSdkDelegate = mockk<WcPairSdkDelegate>()
private val url = "testUrl"
private val source = WcPairUseCase.Source.QR
private val loading = WcPairState.Loading
private val sdkProposal: Wallet.Model.SessionProposal
get() = Wallet.Model.SessionProposal(
pairingTopic = "",
name = "",
description = "",
url = "",
icons = listOf(),
redirect = "",
requiredNamespaces = mapOf(),
optionalNamespaces = mapOf(),
properties = mapOf(),
proposerPublicKey = "",
relayProtocol = "",
relayData = "",
)
private val unsupportedDApp = "Apex Pro"
private val unsupportedSdkProposal get() = sdkProposal.copy(name = unsupportedDApp)
private val sessionForApprove: WcSessionApprove
get() = WcSessionApprove(
wallet = MockUserWalletFactory.create(),
network = listOf(),
)
private val sdkApprove: Wallet.Params.SessionApprove
get() = Wallet.Params.SessionApprove(
proposerPublicKey = "",
namespaces = mapOf(),
)
private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result
get() = Wallet.Model.SettledSessionResponse.Result(
session = sdkSession,
)
private val sdkSession: Wallet.Model.Session
get() = Wallet.Model.Session(
pairingTopic = "",
topic = "",
expiry = 0L,
requiredNamespaces = mapOf(),
optionalNamespaces = mapOf(),
namespaces = mapOf(),
metaData = null,
)
private val Wallet.Model.Session.sessionForSave: WcSession
get() = WcSession(
wallet = sessionForApprove.wallet,
sdkModel = WcSdkSessionConverter.convert(this),
)
private val useCase = DefaultWcPairUseCase(
sessionsManager = sessionsManager,
associateNetworksDelegate = associateNetworksDelegate,
caipNamespaceDelegate = caipNamespaceDelegate,
sdkDelegate = sdkDelegate,
)
@Before
fun setup() {
coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf()
coEvery {
caipNamespaceDelegate.associate(
sessionProposal = sdkProposal,
userWallet = sessionForApprove.wallet,
networks = sessionForApprove.network,
)
} returns mapOf()
}
@Test
fun `pair, emmit proposal state and wait actions`() = runTest {
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
}
assert(awaitItem() is WcPairState.Proposal)
expectNoEvents()
}
}
@Test
fun `success pair and approve flow`() = runTest {
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)
val sessionForSave = sdkSession.sessionForSave
val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right())
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right()
coEvery { sessionsManager.saveSession(sessionForSave) } returns Unit
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
}
assert(awaitItem() is WcPairState.Proposal)
useCase.approve(sessionForApprove)
// ignore
useCase.reject()
assertEquals(approveLoading, awaitItem())
coVerifyOrder {
sdkDelegate.approve(sdkApprove)
sessionsManager.saveSession(sessionForSave)
}
assertEquals(result, awaitItem())
awaitComplete()
}
}
@Test
fun `success pair and reject approving`() = runTest {
val proposerPublicKey = sdkProposal.proposerPublicKey
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
coEvery { sdkDelegate.rejectSession(proposerPublicKey) } returns Unit
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
}
assert(awaitItem() is WcPairState.Proposal)
useCase.reject()
coVerifyOrder {
sdkDelegate.rejectSession(sdkProposal.proposerPublicKey)
}
awaitComplete()
}
}
@Test
fun `success pair and reject unsupported dApp`() = runTest {
coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right()
val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp)
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
}
assertEquals(unsupportedDAppError, awaitItem())
awaitComplete()
}
}
@Test
fun `complete on pair error`() = runTest {
val error = WcPairError.ExternalApprovalError("error")
coEvery { sdkDelegate.pair(url) } returns error.left()
val errorState = WcPairState.Error(error)
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
}
assertEquals(errorState, awaitItem())
awaitComplete()
}
}
@Test
fun `complete on approve error`() = runTest {
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)
val error = WcPairError.ExternalApprovalError("error").left()
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
coEvery { sdkDelegate.approve(sdkApprove) } returns error
val errorResult = WcPairState.Approving.Result(sessionForApprove, error)
useCase.pairFlow(url, source).test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
associateNetworksDelegate.associate(sdkProposal)
}
assert(awaitItem() is WcPairState.Proposal)
useCase.approve(sessionForApprove)
assertEquals(approveLoading, awaitItem())
coVerifyOrder {
sdkDelegate.approve(sdkApprove)
}
assertEquals(errorResult, awaitItem())
awaitComplete()
}
}
}

View file

@ -0,0 +1,254 @@
package com.tangem.domain.walletconnect
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
import com.tangem.data.walletconnect.sign.FinalActionCollector
import com.tangem.data.walletconnect.sign.MiddleActionCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
import io.mockk.every
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
internal class WcSignUseCaseDelegateTest {
private val middleActionCollector = mockk<MiddleActionCollector<TestMiddleAction, TestSignModel>>()
private val finalActionCollector = mockk<FinalActionCollector<TestSignModel>>()
private val useCase = WcSignUseCaseDelegate(
finalActionCollector = finalActionCollector,
middleActionCollector = middleActionCollector,
)
private val initSignModel = TestSignModel()
private val initState = WcSignState(initSignModel, WcSignStep.PreSign)
private val signing = initState.toSigning()
private val result = signing.toResult(Unit.right())
private val testException = RuntimeException("test")
private val successSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
currentState: WcSignState<TestSignModel>,
) -> Unit = { state ->
delay(2)
emit(state.toResult(Unit.right()))
}
private val failedSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
currentState: WcSignState<TestSignModel>,
) -> Unit
get() = { state ->
delay(2)
emit(state.toResult(testException.left()))
}
@Before
fun setup() {
every { middleActionCollector.onMiddleAction } returns { _, _ -> }
every { finalActionCollector.onSign } returns { }
every { finalActionCollector.onCancel } returns { }
}
@Test
fun `invoke and keep flow running`() = runTest {
every { finalActionCollector.onSign } returns successSign
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
expectNoEvents()
}
}
@Test
fun `success sign, keep flow running`() = runTest {
every { finalActionCollector.onSign } returns successSign
useCase.invoke(initModel = initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(result, awaitItem())
expectNoEvents()
}
}
@Test
fun `failed sign, keep flow running`() = runTest {
val failedResult = signing.toResult(testException.left())
every { finalActionCollector.onSign } returns failedSign
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(failedResult, awaitItem())
expectNoEvents()
}
}
@Test
fun `failed sign and catch unknown exception`() = runTest {
val exception = RuntimeException("asd")
val expectedErrorState = signing.toResult(exception.left())
every { finalActionCollector.onSign } returns {
delay(2)
throw exception
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(expectedErrorState, awaitItem())
expectNoEvents()
}
}
@Test
fun `interrupt signing and complete flow on cancel call`() = runTest {
every { finalActionCollector.onSign } returns {
delay(5)
emit(result)
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
delay(2)
useCase.cancel()
awaitComplete()
}
}
@Test
fun `ignore multi time sign call till signed`() = runTest {
var count = 0
val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing)
val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing)
val expectedSignResult = result
every { finalActionCollector.onSign } returns {
// should emit single time in this test
emit(if (count % 2 == 0) startLoading else startLoading2)
count = count.inc()
delay(10)
emit(expectedSignResult)
}
useCase.invoke(initSignModel).test {
useCase.sign()
delay(2)
assertEquals(startLoading, expectMostRecentItem())
// should ignore
useCase.sign()
delay(2)
expectNoEvents()
// should ignore
useCase.sign()
expectNoEvents()
delay(8)
assertEquals(expectedSignResult, expectMostRecentItem())
expectNoEvents()
}
}
@Test
fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest {
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
val firstMiddleUpdate = WcSignState(
signModel = firstTextMode,
domainStep = WcSignStep.PreSign,
)
val startLoading = firstMiddleUpdate.toSigning()
val failedSign = startLoading.toResult(testException.left())
val thirdMiddleUpdate = WcSignState(
signModel = TestSignModel(TestMiddleAction.Three().newTestStr),
domainStep = WcSignStep.PreSign,
)
every { finalActionCollector.onSign } returns {
delay(6)
emit(failedSign)
}
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
}
useCase.invoke(initSignModel).test {
delay(2)
useCase.middleAction(TestMiddleAction.One())
assertEquals(firstMiddleUpdate, expectMostRecentItem())
useCase.sign()
assertEquals(startLoading, awaitItem())
// should ignore
delay(2)
useCase.middleAction(TestMiddleAction.Two())
delay(3)
assertEquals(failedSign, awaitItem())
// continue listen
delay(2)
useCase.middleAction(TestMiddleAction.Three())
assertEquals(thirdMiddleUpdate, awaitItem())
}
}
@Test
fun `buffered middle actions and drop on sign call`() = runTest {
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
val expectedFirst = initState.copy(signModel = firstTextMode)
val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr))
every { finalActionCollector.onSign } returns successSign
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
delay(4)
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.middleAction(TestMiddleAction.One())
useCase.middleAction(TestMiddleAction.Two())
// must be dropped
useCase.middleAction(TestMiddleAction.Three())
// 0 - 4 -> "one" is emitted
// 4 - 8 -> "two" is emitted
// 8 - 12 -> "Signing" is emitted, "three" ignored
delay(2)
assertEquals(expectedFirst, awaitItem())
delay(4)
assertEquals(expectedSecond, awaitItem())
useCase.sign()
delay(4)
assertEquals(expectedSecond.toSigning(), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
internal data class TestSignModel(val testStr: String = "testStr")
internal sealed interface TestMiddleAction {
val newTestStr: String
data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction
data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction
data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction
}
}