Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-30 12:51:57 +03:00
commit 2541461d44
954 changed files with 22834 additions and 21200 deletions

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,42 @@
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)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,127 @@
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.*
private const val SUCCESS_STATUS = "Success"
private const val DOMAIN_CHECKED_STATUS = "hit"
private const val VALIDATION_SAFE_STATUS = "Benign"
internal object BlockAidMapper {
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,39 @@
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
internal class DefaultBlockAidRepository(
private val api: BlockAidApi,
private val dispatchers: CoroutineDispatcherProvider,
private val mapper: BlockAidMapper,
) : BlockAidRepository {
override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult {
val response = withContext(dispatchers.io) {
api.scanDomain(DomainScanRequest(data.url))
}
return mapper.mapToDomain(response)
}
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
val response = withContext(dispatchers.io) {
when (data.params) {
is TransactionParams.Evm -> {
api.scanJsonRpc(mapper.mapToEvmRequest(data))
}
is TransactionParams.Solana -> {
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
}
}
}
return 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,27 @@
package com.tangem.data.blockaid.di
import com.tangem.data.blockaid.BlockAidMapper
import com.tangem.data.blockaid.BlockAidRepository
import com.tangem.data.blockaid.DefaultBlockAidRepository
import com.tangem.datasource.api.common.blockaid.BlockAidApi
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 BlockAidDataInternalModule {
@Provides
@Singleton
fun provideRepository(api: BlockAidApi, dispatchers: CoroutineDispatcherProvider): BlockAidRepository {
return DefaultBlockAidRepository(
api = api,
dispatchers = dispatchers,
mapper = BlockAidMapper,
)
}
}

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.google.common.truth.Truth
import com.tangem.datasource.api.common.blockaid.models.response.*
import org.junit.Test
import java.math.BigDecimal
class BlockAidMapperTest {
private val mapper = BlockAidMapper
@Test
fun `when status hit and is malicious false then map to domain returns safe`() {
val response = DomainScanResponse(status = "hit", isMalicious = false)
val result = mapper.mapToDomain(response)
Truth.assertThat(result).isEqualTo(CheckDAppResult.SAFE)
}
@Test
fun `when status hit and is malicious true then map to domain returns unsafe`() {
val response = DomainScanResponse(status = "hit", isMalicious = true)
val result = mapper.mapToDomain(response)
Truth.assertThat(result).isEqualTo(CheckDAppResult.UNSAFE)
}
@Test
fun `when status not hit then map to domain returns failed to verify`() {
val response = DomainScanResponse(status = "miss", isMalicious = false)
val result = mapper.mapToDomain(response)
Truth.assertThat(result).isEqualTo(CheckDAppResult.FAILED_TO_VERIFY)
}
@Test
fun `when response benign validation then returns safe validation`() {
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)
Truth.assertThat(result.validation).isEqualTo(ValidationResult.SAFE)
val simulation = result.simulation as? SimulationResult.Success
Truth.assertThat(simulation).isNotNull()
val approve = simulation?.data as? SimulationData.Approve
Truth.assertThat(approve).isNotNull()
Truth.assertThat(approve?.approvedAmounts?.size).isEqualTo(1)
Truth.assertThat(approve?.approvedAmounts?.first()?.approvedAmount).isEqualTo(BigDecimal("1000.0"))
Truth.assertThat(approve?.approvedAmounts?.first()?.isUnlimited).isTrue()
}
@Test
fun `when response benign validation and success simulation then returns send receive result`() {
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)
Truth.assertThat(result.validation).isEqualTo(ValidationResult.SAFE)
val simulation = result.simulation as? SimulationResult.Success
Truth.assertThat(simulation).isNotNull()
val data = simulation?.data as? SimulationData.SendAndReceive
Truth.assertThat(data).isNotNull()
Truth.assertThat(data?.send?.first()?.amount).isEqualTo(BigDecimal("1.5"))
Truth.assertThat(data?.receive?.first()?.amount).isEqualTo(BigDecimal("2.0"))
}
@Test
fun `when response error validation rhen returns failed to validate`() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Error", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
Truth.assertThat(result.validation).isEqualTo(ValidationResult.FAILED_TO_VALIDATE)
Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue()
}
@Test
fun `when response not benign then returns validation unsafe`() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Phishing"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
Truth.assertThat(result.validation).isEqualTo(ValidationResult.UNSAFE)
}
@Test
fun `when response simulation not success then returns simulation failed ro simulate`() {
val response = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Error",
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
),
)
val result = mapper.mapToDomain(response)
Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue()
}
@Test
fun `when response simulation is empty then returns failed to simulate`() {
val txResponse = TransactionScanResponse(
validation = ValidationResponse(status = "Success", resultType = "Benign"),
simulation = SimulationResponse(
status = "Success",
accountSummary = AccountSummaryResponse(
assetsDiffs = emptyList(),
exposures = emptyList(),
),
),
)
val result = mapper.mapToDomain(txResponse)
Truth.assertThat(result.simulation is SimulationResult.FailedToSimulate).isTrue()
}
}

View file

@ -0,0 +1,108 @@
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.google.common.truth.Truth
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.TestingCoroutineDispatcherProvider
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class DefaultBlockAidRepositoryTest {
@MockK
private lateinit var api: BlockAidApi
@MockK
private lateinit var mapper: BlockAidMapper
private lateinit var repository: DefaultBlockAidRepository
private val dispatchers = TestingCoroutineDispatcherProvider()
@Before
fun setup() {
MockKAnnotations.init(this)
repository = DefaultBlockAidRepository(api, dispatchers, mapper)
}
@Test
fun `when verify app domain then calls api and maps result`() = 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)
Truth.assertThat(result).isEqualTo(expectedResult)
coVerify { api.scanDomain(DomainScanRequest(url)) }
verify { mapper.mapToDomain(domainResponse) }
}
@Test
fun `when verify evm transaction then calls scan json rpc and maps result`() = 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)
Truth.assertThat(result).isEqualTo(expectedResult)
coVerify { api.scanJsonRpc(request) }
verify { mapper.mapToEvmRequest(data) }
verify { mapper.mapToDomain(response) }
}
@Test
fun `when verify solana transaction then calls scan solana message and maps result`() = 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)
Truth.assertThat(result).isEqualTo(expectedResult)
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

@ -30,6 +30,9 @@ dependencies {
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Feature Api modules */
implementation(projects.features.nft.api)
/** Libs - Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)

View file

@ -2,7 +2,9 @@ package com.tangem.data.nft
import arrow.core.Either
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.getNetwork
import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
@ -10,7 +12,9 @@ import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
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.datasource.local.userwallet.UserWalletsStore
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
@ -18,6 +22,7 @@ import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.nft.NFTFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -25,17 +30,21 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.lang.UnsupportedOperationException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
@Suppress("LargeClass")
@Suppress("LargeClass", "LongParameterList")
internal class DefaultNFTRepository @Inject constructor(
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory,
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory,
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
private val excludedBlockchains: ExcludedBlockchains,
private val userWalletsStore: UserWalletsStore,
private val nftFeatureToggles: NFTFeatureToggles,
) : NFTRepository {
private val networkJobs = ConcurrentHashMap<Network, JobHolder>()
@ -63,38 +72,11 @@ internal class DefaultNFTRepository @Inject constructor(
flowOf(NFTCollections.empty(network))
}
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) = coroutineScope {
networks.mapNotNull { network ->
if (network.canHandleNFTs()) {
launch(dispatchers.io) {
Either.catch {
expireCollections(userWalletId, network)
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) =
refreshCollectionsInternal(userWalletId, networks, refreshAssets = false)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getNetworkJobHolder(network))
} else {
null
}
}.joinAll()
override suspend fun refreshAll(userWalletId: UserWalletId, networks: List<Network>) {
refreshCollectionsInternal(userWalletId, networks, refreshAssets = true)
}
override suspend fun refreshAssets(
@ -104,8 +86,6 @@ internal class DefaultNFTRepository @Inject constructor(
) = coroutineScope {
launch(dispatchers.io) {
Either.catch {
expireAssets(userWalletId, network, collectionId)
val sdkCollectionId = NFTSdkCollectionIdentifierConverter.convertBack(collectionId)
val assets = walletManagersFacade.getNFTAssets(
@ -114,10 +94,12 @@ internal class DefaultNFTRepository @Inject constructor(
collectionIdentifier = sdkCollectionId,
)
expireAssets(userWalletId, network, collectionId)
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)
}
}
@ -144,17 +126,88 @@ internal class DefaultNFTRepository @Inject constructor(
)
}
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
if (it !is UnsupportedOperationException) {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}
}.saveIn(getCollectionJobHolder(collectionId)).join()
}
override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs()
override suspend fun getNFTSupportedNetworks(userWalletId: UserWalletId): List<Network> {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
return Blockchain
.entries
.filter { it.canHandleNFTs() && !it.isTestnet() }
.mapNotNull {
getNetwork(
blockchain = it,
extraDerivationPath = null,
scanResponse = userWallet.scanResponse,
excludedBlockchains = excludedBlockchains,
)
}
}
override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? =
walletManagersFacade.getNFTExploreUrl(
network = network,
assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier),
)
private suspend fun refreshCollectionsInternal(
userWalletId: UserWalletId,
networks: List<Network>,
refreshAssets: Boolean,
) = coroutineScope {
networks.mapNotNull { network ->
if (network.canHandleNFTs()) {
launch(dispatchers.io) {
Either.catch {
expireCollections(userWalletId, network)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
if (refreshAssets) {
mergedCollections.forEach { collection ->
refreshAssets(
userWalletId = userWalletId,
network = network,
collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier),
)
}
}
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getNetworkJobHolder(network))
} else {
null
}
}.joinAll()
}
private suspend fun refreshSalePrice(
userWalletId: UserWalletId,
network: Network,
@ -431,5 +484,14 @@ internal class DefaultNFTRepository @Inject constructor(
return walletId.stringValue + "_" + network.id.value + "_" + network.derivationPath.value
}
private fun Network.canHandleNFTs(): Boolean = Blockchain.fromNetworkId(backendId)?.canHandleNFTs() == true
private fun Network.canHandleNFTs(): Boolean {
val blockchain = Blockchain.fromNetworkId(backendId)
return when {
blockchain == null -> false
blockchain.isEvm() && !nftFeatureToggles.isNFTEVMEnabled -> false
(blockchain == Blockchain.Solana || blockchain == Blockchain.SolanaTestnet) &&
!nftFeatureToggles.isNFTSolanaEnabled -> false
else -> blockchain.canHandleNFTs()
}
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.notifications"
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
// endregion
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Arrow
implementation(deps.arrow.core)
// endregion
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Domain modules
implementation(projects.domain.notifications.models)
implementation(projects.domain.notifications)
// endregion
// region tests
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,84 @@
package com.tangem.data.notifications
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.api.tangemTech.models.WalletIdBody
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultNotificationsRepository @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appInfoProvider: AppInfoProvider,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : NotificationsRepository {
override suspend fun createApplicationId(pushToken: String?): String = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform,
device = appInfoProvider.device,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
pushToken = pushToken,
),
).getOrThrow().appId
}
override suspend fun saveApplicationId(appId: String) {
appPreferencesStore.store(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY, appId)
}
override suspend fun getApplicationId(): String? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY)
}
override suspend fun associateApplicationIdWithWallets(appId: String, wallets: List<String>) =
withContext(dispatchers.io) {
tangemTechApi.associateApplicationIdWithWallets(
applicationId = appId,
body = wallets.map {
WalletIdBody(it)
},
).getOrThrow()
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId,
WalletBody(name = walletName),
).getOrThrow()
}
override suspend fun getWalletName(walletId: String): String? = withContext(dispatchers.io) {
tangemTechApi.getWalletById(walletId).getOrThrow().name
}
override suspend fun sendPushToken(appId: String, pushToken: String) {
withContext(dispatchers.io) {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(
pushToken = pushToken,
),
).getOrThrow()
}
}
override suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork> = withContext(dispatchers.io) {
tangemTechApi.getEligibleNetworksForPushNotifications().getOrThrow().map {
NotificationsEligibleNetworkConverter.convert(it)
}
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.data.notifications.converters
import com.tangem.datasource.api.tangemTech.models.CryptoNetworkResponse
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.converter.Converter
internal object NotificationsEligibleNetworkConverter : Converter<CryptoNetworkResponse, NotificationsEligibleNetwork> {
override fun convert(value: CryptoNetworkResponse): NotificationsEligibleNetwork {
return NotificationsEligibleNetwork(
id = value.id.toString(),
networkId = value.networkId,
name = value.name,
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.notifications.di
import com.tangem.data.notifications.DefaultNotificationsRepository
import com.tangem.domain.notifications.repository.NotificationsRepository
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 NotificationsModule {
@Binds
@Singleton
fun bindNotificationsRepository(repository: DefaultNotificationsRepository): NotificationsRepository
}

View file

@ -0,0 +1,216 @@
package com.tangem.data.notifications
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultNotificationsRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val appInfoProvider: AppInfoProvider = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val repository = DefaultNotificationsRepository(
tangemTechApi = tangemTechApi,
appInfoProvider = appInfoProvider,
appPreferencesStore = appPreferencesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN valid push token WHEN createApplicationId THEN returns application id`() = runTest {
// GIVEN
val pushToken = "test-push-token"
val expectedAppId = "test-app-id"
val expectedAppIdResponse = NotificationApplicationIdResponse(
appId = expectedAppId,
)
coEvery { appInfoProvider.platform } returns "android"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success(
expectedAppIdResponse,
)
// WHEN
val result = repository.createApplicationId(pushToken)
// THEN
assertThat(result).isEqualTo(expectedAppId)
coVerify {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = "android",
device = "test-device",
systemVersion = "11",
language = "en",
timezone = "UTC",
pushToken = pushToken,
),
)
}
}
@Test
fun `GIVEN application id WHEN saveApplicationId THEN stores it in preferences`() = runTest {
// GIVEN
val appId = "test-app-id"
val preferences = mockk<Preferences>(relaxed = true)
coEvery { preferencesDataStore.updateData(any()) } returns preferences
// WHEN
repository.saveApplicationId(appId)
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN stored application id WHEN getApplicationId THEN returns it`() = runTest {
// GIVEN
val expectedAppId = "test-app-id"
val preferences = mockk<Preferences>(relaxed = true)
val key = stringPreferencesKey(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY.name)
every { preferences[key] } returns expectedAppId
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
val result = repository.getApplicationId()
// THEN
assertThat(result).isEqualTo(expectedAppId)
}
@Test
fun `GIVEN application id and wallet list WHEN associateApplicationIdWithWallets THEN associates them`() = runTest {
// GIVEN
val appId = "test-app-id"
val wallets = listOf("wallet1", "wallet2")
coEvery {
tangemTechApi.associateApplicationIdWithWallets(
appId,
wallets.map { WalletIdBody(it) },
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.associateApplicationIdWithWallets(appId, wallets)
// THEN
coVerify { tangemTechApi.associateApplicationIdWithWallets(appId, wallets.map { WalletIdBody(it) }) }
}
@Test
fun `GIVEN wallet id and name WHEN setWalletName THEN updates wallet name`() = runTest {
// GIVEN
val walletId = "test-wallet-id"
val walletName = "Test Wallet"
coEvery {
tangemTechApi.updateWallet(
walletId,
WalletBody(name = walletName),
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.setWalletName(walletId, walletName)
// THEN
coVerify { tangemTechApi.updateWallet(walletId, WalletBody(name = walletName)) }
}
@Test
fun `GIVEN wallet id WHEN getWalletName THEN returns wallet name`() = runTest {
// GIVEN
val walletId = "test-wallet-id"
val expectedName = "Test Wallet"
coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success(
WalletResponse(
notifyStatus = false,
name = expectedName,
id = walletId,
),
)
// WHEN
val result = repository.getWalletName(walletId)
// THEN
assertThat(result).isEqualTo(expectedName)
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest {
// GIVEN
val appId = "test-app-id"
val pushToken = "test-push-token"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(pushToken = pushToken),
)
} returns ApiResponse.Success(appId)
// WHEN
repository.sendPushToken(appId, pushToken)
// THEN
coVerify {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(pushToken = pushToken),
)
}
}
@Test
fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest {
// GIVEN
val expectedNetworks = listOf(
CryptoNetworkResponse(
id = 1,
name = "Ethereum",
networkId = "Ethereum",
),
CryptoNetworkResponse(
id = 2,
name = "Bitcoin",
networkId = "bitcoin",
),
)
coEvery { tangemTechApi.getEligibleNetworksForPushNotifications() } returns ApiResponse.Success(
expectedNetworks,
)
// WHEN
val result = repository.getEligibleNetworks()
// THEN
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[0]))
assertThat(result[1]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[1]))
coVerify { tangemTechApi.getEligibleNetworksForPushNotifications() }
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.onramp
import android.net.Uri
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
@ -24,6 +24,7 @@ import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore
import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
@ -43,6 +44,7 @@ import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
@ -82,10 +84,16 @@ internal class DefaultOnrampRepository(
override fun getCurrencies(): Flow<List<OnrampCurrency>> = currenciesStore.get(CURRENCIES_KEY)
override suspend fun fetchCurrencies() = withContext(dispatchers.io) {
override suspend fun fetchCurrencies(userWallet: UserWallet) = withContext(dispatchers.io) {
if (!currenciesStore.getSyncOrNull(CURRENCIES_KEY).isNullOrEmpty()) return@withContext
val result = onrampApi.getCurrencies()
val result = onrampApi.getCurrencies(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
)
.getOrThrow()
.map(currencyConverter::convert)
@ -98,10 +106,16 @@ internal class DefaultOnrampRepository(
return countriesStore.getSyncOrNull(COUNTRIES_KEY)
}
override suspend fun fetchCountries(): List<OnrampCountry> = withContext(dispatchers.io) {
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
val result = onrampApi.getCountries()
val result = onrampApi.getCountries(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
)
.getOrThrow()
.map(countryConverter::convert)
@ -110,14 +124,27 @@ internal class DefaultOnrampRepository(
result
}
override suspend fun getCountryByIp(): OnrampCountry = withContext(dispatchers.io) {
onrampApi.getCountryByIp()
override suspend fun getCountryByIp(userWallet: UserWallet): OnrampCountry = withContext(dispatchers.io) {
onrampApi.getCountryByIp(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
)
.getOrThrow()
.let(countryConverter::convert)
}
override suspend fun getStatus(txId: String): OnrampStatus = withContext(dispatchers.io) {
onrampApi.getStatus(txId)
override suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus = withContext(dispatchers.io) {
onrampApi.getStatus(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
txId = txId,
)
.getOrThrow()
.let(statusConverter::convert)
}
@ -161,11 +188,19 @@ internal class DefaultOnrampRepository(
.map { it?.let(countryConverter::convert) }
}
override suspend fun fetchPaymentMethodsIfAbsent() = withContext(dispatchers.io) {
override suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet) = withContext(dispatchers.io) {
if (paymentMethodsStore.contains(PAYMENT_METHODS_KEY)) return@withContext
val response = safeApiCall(
call = { onrampApi.getPaymentMethods().bind() },
call = {
onrampApi.getPaymentMethods(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp payment methods")
throw it
@ -174,100 +209,123 @@ internal class DefaultOnrampRepository(
paymentMethodsStore.store(PAYMENT_METHODS_KEY, response.removeApplePay())
}
override suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency) =
withContext(dispatchers.io) {
val onrampPairs = async {
safeApiCall(
call = {
onrampApi.getPairs(
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
),
override suspend fun fetchPairs(
userWallet: UserWallet,
currency: OnrampCurrency,
country: OnrampCountry,
cryptoCurrency: CryptoCurrency,
) = withContext(dispatchers.io) {
val onrampPairs = async {
safeApiCall(
call = {
onrampApi.getPairs(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
),
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
}
val providers = async {
safeApiCall(
call = { expressApi.getProviders().bind() },
onError = {
Timber.w(it, "Unable to fetch express providers")
throw it
},
)
}
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
}
val providers = async {
safeApiCall(
call = {
expressApi.getProviders(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch express providers")
throw it
},
)
}
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
}
override suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
"Unable to get pairs. At this point they must not be null."
}
val amountValue = requireNotNull(amount.value) { "Amount value must not be null" }
val fromAmount = amountValue.movePointRight(amount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val fromOnrampAmount = OnrampAmount(
value = fromAmount
.toBigDecimalOrDefault()
.movePointLeft(currency.precision),
decimals = currency.precision,
symbol = amount.currencySymbol,
)
val quotes: List<OnrampQuote> = pairs.flatMap { pair ->
pair.providers.flatMap { provider ->
provider.paymentMethods.map { paymentMethod ->
async {
safeApiCall(
call = {
val response = onrampApi.getQuote(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmount,
toDecimals = cryptoCurrency.decimals,
providerId = provider.id,
).bind()
OnrampQuote.Data(
fromAmount = fromOnrampAmount,
toAmount = convertToAmount(response.toAmount, cryptoCurrency),
minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency),
maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency),
paymentMethod = paymentMethod,
provider = provider,
countryCode = response.countryCode,
)
},
onError = { error ->
convertQuoteError(
error = error,
paymentMethod = paymentMethod,
provider = provider,
fromOnrampAmount = fromOnrampAmount,
countryCode = country.code,
)
},
)
override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) =
withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
"Unable to get pairs. At this point they must not be null."
}
val amountValue = requireNotNull(amount.value) { "Amount value must not be null" }
val fromAmount = amountValue.movePointRight(amount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val fromOnrampAmount = OnrampAmount(
value = fromAmount
.toBigDecimalOrDefault()
.movePointLeft(currency.precision),
decimals = currency.precision,
symbol = amount.currencySymbol,
)
val quotes: List<OnrampQuote> = pairs.flatMap { pair ->
pair.providers.flatMap { provider ->
provider.paymentMethods.map { paymentMethod ->
async {
safeApiCall(
call = {
val response = onrampApi.getQuote(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmount,
toDecimals = cryptoCurrency.decimals,
providerId = provider.id,
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).bind()
OnrampQuote.Data(
fromAmount = fromOnrampAmount,
toAmount = convertToAmount(response.toAmount, cryptoCurrency),
minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency),
maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency),
paymentMethod = paymentMethod,
provider = provider,
countryCode = response.countryCode,
)
},
onError = { error ->
convertQuoteError(
error = error,
paymentMethod = paymentMethod,
provider = provider,
fromOnrampAmount = fromOnrampAmount,
countryCode = country.code,
)
},
)
}
}
}
}
}.awaitAll().filterNotNull()
quotesStore.store(QUOTES_KEY, quotes)
}
}.awaitAll().filterNotNull()
quotesStore.store(QUOTES_KEY, quotes)
}
override fun getQuotes(): Flow<List<OnrampQuote>> {
return quotesStore.get(QUOTES_KEY)
@ -278,20 +336,21 @@ internal class DefaultOnrampRepository(
}
override suspend fun getOnrampData(
userWalletId: UserWalletId,
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
quote: OnrampProviderWithQuote.Data,
isDarkTheme: Boolean,
): OnrampTransaction = withContext(dispatchers.io) {
try {
val address = requireNotNull(
value = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network),
value = walletManagersFacade.getDefaultAddress(userWallet.walletId, cryptoCurrency.network),
lazyMessage = { "Address must not be null" },
)
val fromAmountString = quote.fromAmount.value.movePointRight(quote.fromAmount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val requestId = UUID.randomUUID().toString()
val redirectUrl = quote.provider.toRedirectUrl() ?: error("Invalid onramp redirect url")
val data = safeApiCall(
call = {
onrampApi.getData(
@ -305,10 +364,15 @@ internal class DefaultOnrampRepository(
toDecimals = cryptoCurrency.decimals,
providerId = quote.provider.id,
toAddress = address,
redirectUrl = BuyCurrencyDeepLink.ONRAMP_REDIRECT_DEEPLINK,
redirectUrl = redirectUrl,
language = null,
theme = getTheme(isDarkTheme),
requestId = requestId,
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
).takeUnless { it.isEmpty() },
).bind()
},
onError = { e ->
@ -326,7 +390,7 @@ internal class DefaultOnrampRepository(
txId = data.txId,
quote = quote,
onrampDataJson = dataJson,
userWalletId = userWalletId,
userWalletId = userWallet.walletId,
currency = currency,
cryptoCurrency = cryptoCurrency,
residency = country.name,
@ -482,6 +546,11 @@ internal class DefaultOnrampRepository(
null
}
private fun OnrampProvider.toRedirectUrl() = Uri.Builder()
.path(REDIRECT_URL)
.appendPath(id)
.build().path
private companion object {
const val PAYMENT_METHODS_KEY = "onramp_payment_methods"
const val SELECTED_PAYMENT_METHOD_KEY = "onramp_selected_payment_method"
@ -491,5 +560,7 @@ internal class DefaultOnrampRepository(
const val CURRENCIES_KEY = "onramp_currencies"
const val PROVIDER_THEME_DARK = "dark"
const val PROVIDER_THEME_LIGHT = "light"
const val REDIRECT_URL = "https://tangem.com/onramp"
}
}

View file

@ -32,7 +32,7 @@ internal class DefaultOnrampTransactionRepository(
)?.map(transactionConverter::convert) ?: mutableSetOf()
val updated = stored.toMutableSet()
.addOrReplace(transaction) { it.externalTxId == transaction.externalTxId }
.addOrReplace(transaction) { it.txId == transaction.txId }
.map(transactionConverter::convertBack)
.toSet()
@ -44,12 +44,12 @@ internal class DefaultOnrampTransactionRepository(
}
}
override suspend fun getTransactionById(externalTxId: String): OnrampTransaction? = withContext(dispatchers.io) {
override suspend fun getTransactionById(txId: String): OnrampTransaction? = withContext(dispatchers.io) {
val stored = appPreferencesStore.getObjectSetSync<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
).map(transactionConverter::convert)
stored.firstOrNull { it.externalTxId == externalTxId }
stored.firstOrNull { it.txId == txId }
}
override fun getTransactions(
@ -63,19 +63,16 @@ internal class DefaultOnrampTransactionRepository(
}.map(transactionConverter::convert)
}
override suspend fun updateTransactionStatus(
externalTxId: String,
externalTxUrl: String,
status: OnrampStatus.Status,
) = withContext(dispatchers.io) {
val updatedTx = getTransactionById(externalTxId)?.copy(
externalTxUrl = externalTxUrl,
status = status,
) ?: return@withContext
storeTransaction(updatedTx)
}
override suspend fun updateTransactionStatus(txId: String, externalTxUrl: String, status: OnrampStatus.Status) =
withContext(dispatchers.io) {
val updatedTx = getTransactionById(txId)?.copy(
externalTxUrl = externalTxUrl,
status = status,
) ?: return@withContext
storeTransaction(updatedTx)
}
override suspend fun removeTransaction(externalTxId: String) {
override suspend fun removeTransaction(txId: String) {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
@ -83,7 +80,7 @@ internal class DefaultOnrampTransactionRepository(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
)?.toMutableSet()
stored?.removeIf { it.externalTxId == externalTxId }
stored?.removeIf { it.txId == txId }
mutablePreferences.setObjectSet<OnrampTransactionDTO>(
key = PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,

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,101 @@
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,
ifNotFound = Quote::Empty,
source = StatusSource.ONLY_CACHE,
)
}
private suspend fun updateStatusSourceInRuntime(
currenciesIds: Set<CryptoCurrency.RawID>,
ifNotFound: (CryptoCurrency.RawID) -> Quote? = { null },
source: StatusSource,
) {
runtimeStore.update(default = emptySet()) { stored ->
val updatedQuotes = currenciesIds.mapNotNullTo(hashSetOf()) { id ->
val quote = stored.firstOrNull { it.rawCurrencyId == id }
?: ifNotFound(id)
?: return@mapNotNullTo null
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,129 @@
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)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptySet<Quote>())
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

@ -24,6 +24,9 @@ import com.tangem.data.staking.converters.transaction.GasEstimateConverter
import com.tangem.data.staking.converters.transaction.StakingTransactionConverter
import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter
import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.StakingIdFactory.Companion.integrationIdMap
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -64,7 +67,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
@ -75,11 +77,13 @@ internal class DefaultStakingRepository(
private val stakeKitApi: StakeKitApi,
private val stakingYieldsStore: StakingYieldsStore,
private val stakingBalanceStore: StakingBalanceStore,
private val stakingBalanceStoreV2: YieldsBalancesStore,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
private val walletManagersFacade: WalletManagersFacade,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
private val stakingIdFactory: StakingIdFactory,
moshi: Moshi,
) : StakingRepository {
@ -428,7 +432,7 @@ internal class DefaultStakingRepository(
}
}.cancellable()
override suspend fun getSingleYieldBalanceSync(
override suspend fun getSingleYieldBalanceSyncLegacy(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): YieldBalance = withContext(dispatchers.io) {
@ -446,6 +450,20 @@ internal class DefaultStakingRepository(
?: YieldBalance.Error(integrationId, address)
}
override suspend fun getSingleYieldBalanceSync(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): YieldBalance {
val stakingId = stakingIdFactory.createForDefault(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
) ?: error("Could not create stakingId")
return stakingBalanceStoreV2.getSyncOrNull(userWalletId = userWalletId, stakingId = stakingId)
?: YieldBalance.Error(integrationId = stakingId.integrationId, address = stakingId.address)
}
@Suppress("LongMethod")
override suspend fun fetchMultiYieldBalance(
userWalletId: UserWalletId,
@ -558,26 +576,7 @@ internal class DefaultStakingRepository(
}
}
override fun getMultiYieldBalanceUpdatesLegacy(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Flow<YieldBalanceList> = channelFlow {
stakingBalanceStore.get(
userWalletId = userWalletId,
stakingIds = cryptoCurrencies.mapStakingId(userWalletId),
)
.onEach {
val balances = YieldBalanceListConverter.convert(it)
send(balances)
}
.launchIn(scope = this + dispatchers.io)
withContext(dispatchers.io) {
fetchMultiYieldBalance(userWalletId, cryptoCurrencies, refresh = false)
}
}
override suspend fun getMultiYieldBalanceSync(
override suspend fun getMultiYieldBalanceSyncLegacy(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): YieldBalanceList = withContext(dispatchers.io) {
@ -588,6 +587,20 @@ internal class DefaultStakingRepository(
?: YieldBalanceList.Error
}
override suspend fun getMultiYieldBalanceSync(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): YieldBalanceList {
val stakingIds = cryptoCurrencies.flatMap {
stakingIdFactory.create(userWalletId = userWalletId, currencyId = it.id, network = it.network)
}
return stakingBalanceStoreV2.getAllSyncOrNull(userWalletId)
?.filter { it.getStakingId() in stakingIds }
?.let { YieldBalanceListConverter.convert(value = it.toSet()) }
?: YieldBalanceList.Error
}
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
stakingBalanceStore.getSyncOrNull(userWalletId)
@ -729,45 +742,13 @@ internal class DefaultStakingRepository(
}
@Suppress("unused")
private companion object {
const val YIELDS_STORE_KEY = "yields"
companion object {
private const val YIELDS_STORE_KEY = "yields"
const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking"
const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking"
const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking"
const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking"
const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking"
const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking"
const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking"
const val TRON_INTEGRATION_ID = "tron-trx-native-staking"
const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking"
const val KAVA_INTEGRATION_ID = "kava-kava-native-staking"
const val NEAR_INTEGRATION_ID = "near-near-native-staking"
const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking"
const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking"
private const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908"
const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908"
internal val YIELDS_WATITING_TIMEOUT = 15.seconds
val YIELDS_WATITING_TIMEOUT = 15.seconds
val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79")
// uncomment items as implementation is ready
val integrationIdMap = mapOf(
Blockchain.TON.run { id + toCoinId() } to TON_INTEGRATION_ID,
Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID,
Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID,
Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID,
Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
// Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID,
// Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID,
// Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID,
// Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID,
// Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID,
// Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID,
// Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID,
Blockchain.Cardano.run { id + toCoinId() } to CARDANO_INTEGRATION_ID,
)
private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79")
}
}

View file

@ -9,7 +9,9 @@ import com.tangem.data.staking.DefaultStakingErrorResolver
import com.tangem.data.staking.DefaultStakingRepository
import com.tangem.data.staking.DefaultStakingTransactionHashRepository
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.di.NetworkMoshi
@ -41,23 +43,27 @@ internal object StakingDataModule {
stakeKitApi: StakeKitApi,
stakingYieldsStore: StakingYieldsStore,
stakingBalanceStore: StakingBalanceStore,
yieldsBalancesStore: YieldsBalancesStore,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
walletManagersFacade: WalletManagersFacade,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
stakingIdFactory: StakingIdFactory,
@NetworkMoshi moshi: Moshi,
): StakingRepository {
return DefaultStakingRepository(
stakeKitApi = stakeKitApi,
stakingYieldsStore = stakingYieldsStore,
stakingBalanceStore = stakingBalanceStore,
stakingBalanceStoreV2 = yieldsBalancesStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
walletManagersFacade = walletManagersFacade,
getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles,
moshi = moshi,
stakingIdFactory = stakingIdFactory,
)
}
@ -89,7 +95,7 @@ internal object StakingDataModule {
@Provides
@Singleton
internal fun provideStakingErrorResolver(
fun provideStakingErrorResolver(
@NetworkMoshi moshi: Moshi,
analyticsEventHandler: AnalyticsEventHandler,
): StakingErrorResolver {
@ -102,7 +108,7 @@ internal object StakingDataModule {
@Provides
@Singleton
internal fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles {
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles {
return DefaultStakingFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiYieldBalanceFetcher
import com.tangem.data.staking.single.DefaultSingleYieldBalanceFetcher
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
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 YieldBalanceFetcherModule {
@Binds
@Singleton
fun bindSingleYieldBalanceFetcher(impl: DefaultSingleYieldBalanceFetcher): SingleYieldBalanceFetcher
@Binds
@Singleton
fun bindMultiYieldBalanceFetcher(impl: DefaultMultiYieldBalanceFetcher): MultiYieldBalanceFetcher
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiYieldBalanceProducer
import com.tangem.data.staking.single.DefaultSingleYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
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 YieldBalanceProducerFactoryModule {
@Binds
@Singleton
fun bindSingleYieldBalanceProducerFactory(
impl: DefaultSingleYieldBalanceProducer.Factory,
): SingleYieldBalanceProducer.Factory
@Binds
@Singleton
fun bindMultiYieldBalanceProducerFactory(
impl: DefaultMultiYieldBalanceProducer.Factory,
): MultiYieldBalanceProducer.Factory
}

View file

@ -0,0 +1,56 @@
package com.tangem.data.staking.di
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.DefaultYieldsBalancesStore
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.staking.single.SingleYieldBalanceSupplier
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 YieldBalanceSupplierModule {
@Provides
@Singleton
fun provideYieldsBalancesStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
dispatchers: CoroutineDispatcherProvider,
): YieldsBalancesStore {
return DefaultYieldsBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideSingleYieldBalanceSupplier(factory: SingleYieldBalanceProducer.Factory): SingleYieldBalanceSupplier {
return object : SingleYieldBalanceSupplier(
factory = factory,
keyCreator = {
"single_yield_balance_${it.userWalletId.stringValue}_${it.network.id.value}_" +
it.network.derivationPath.value
},
) {}
}
@Provides
@Singleton
fun provideMultiYieldBalanceSupplier(factory: MultiYieldBalanceProducer.Factory): MultiYieldBalanceSupplier {
return object : MultiYieldBalanceSupplier(
factory = factory,
keyCreator = { "multi_yields_balances_${it.userWalletId.stringValue}" },
) {}
}
}

View file

@ -0,0 +1,141 @@
package com.tangem.data.staking.fetcher
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.toOption
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
internal fun <Params : YieldBalanceFetcherParams> commonFetcher(
implementor: YieldBalanceFetcherImplementor<Params>,
userWalletsStore: UserWalletsStore,
stakingYieldsStore: StakingYieldsStore,
yieldsBalancesStore: YieldsBalancesStore,
dispatchers: CoroutineDispatcherProvider,
): FlowFetcher<Params> {
return CommonYieldBalanceFetcher(
implementor = implementor,
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
}
/**
* Common implementation of YieldBalanceFetcher
*
* @property implementor fetcher implementor
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property dispatchers dispatchers
*/
private class CommonYieldBalanceFetcher<Params : YieldBalanceFetcherParams>(
private val implementor: YieldBalanceFetcherImplementor<Params>,
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : FlowFetcher<Params> {
override suspend fun invoke(params: Params): Either<Throwable, Unit> {
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
val stakingIds = getStakingIds(params).getOrElse {
return it.left()
}
return Either.catchOn(dispatchers.default) {
val requests = prefetch(userWalletId = params.userWalletId, stakingIds = stakingIds)
implementor.fetch(params = params, stakingIds = stakingIds, requests)
}
.onLeft {
Timber.e(it, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
}
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()
val isSupportedByWallet = maybeUserWallet.isSome(UserWallet::isMultiCurrency)
if (!isSupportedByWallet) {
val exception = IllegalStateException("Wallet $userWalletId is not supported: $maybeUserWallet")
Timber.e(exception)
ifNotSupported(exception)
}
}
private suspend fun getStakingIds(params: Params): Either<Throwable, Set<StakingID>> = either {
val stakingIds = catch(
block = { implementor.createStakingIds(params = params) },
catch = { raise(it) },
)
ensure(stakingIds.isNotEmpty()) {
val exception = IllegalStateException("Unable to create staking ids for $params: list is empty")
Timber.e(exception)
raise(exception)
}
stakingIds
}
private suspend fun prefetch(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
): List<YieldBalanceRequestBody> {
yieldsBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val yieldDTOs = stakingYieldsStore.getSyncWithTimeout()
if (yieldDTOs.isNullOrEmpty()) {
val exception = IllegalStateException("No enabled yields for $userWalletId")
Timber.e(exception)
throw exception
}
val yieldIds = yieldDTOs.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id)
val requests = stakingIds
.filter { stakingId -> yieldIds.any { it == stakingId.integrationId } }
.map(YieldBalanceRequestBodyFactory::create)
if (requests.isEmpty()) {
val exception = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${stakingIds.joinToString()}
""".trimIndent(),
)
Timber.d(exception)
throw exception
}
return requests
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.staking.fetcher
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
/**
* Implementor of internal logic of YieldBalanceFetcher
*
[REDACTED_AUTHOR]
*/
internal interface YieldBalanceFetcherImplementor<in Params : YieldBalanceFetcherParams> {
/** Create set of [StakingID] */
suspend fun createStakingIds(params: Params): Set<StakingID>
/**
* Fetch yield balances
*
* @param params params
* @param stakingIds set of [StakingID]
* @param requests requests
*/
suspend fun fetch(params: Params, stakingIds: Set<StakingID>, requests: List<YieldBalanceRequestBody>)
}

View file

@ -0,0 +1,56 @@
package com.tangem.data.staking.multi
import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor
import com.tangem.data.staking.fetcher.commonFetcher
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating StakingID
* @property stakeKitApi stake kit API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiYieldBalanceFetcher,
FlowFetcher<YieldBalanceFetcherParams.Multi> by commonFetcher(
implementor = createMultiFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers),
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
private fun createMultiFetcherImplementor(
yieldsBalancesStore: YieldsBalancesStore,
stakingIdFactory: StakingIdFactory,
stakeKitApi: StakeKitApi,
dispatchers: CoroutineDispatcherProvider,
): YieldBalanceFetcherImplementor<YieldBalanceFetcherParams.Multi> {
return MultiYieldBalanceFetcherImplementor(
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = dispatchers,
)
}

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,58 @@
package com.tangem.data.staking.multi
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Implementor of fetcher for refreshing multiple yield balances
*
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating [StakingID]
* @property stakeKitApi StakeKit API
* @property dispatchers dispatchers
*/
internal class MultiYieldBalanceFetcherImplementor(
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldBalanceFetcherImplementor<YieldBalanceFetcherParams.Multi> {
override suspend fun createStakingIds(params: YieldBalanceFetcherParams.Multi): Set<StakingID> {
return params.currencyIdWithNetworkMap.flatMapTo(hashSetOf()) { (currencyId, network) ->
stakingIdFactory.create(userWalletId = params.userWalletId, currencyId = currencyId, network = network)
}
}
override suspend fun fetch(
params: YieldBalanceFetcherParams.Multi,
stakingIds: Set<StakingID>,
requests: List<YieldBalanceRequestBody>,
) {
safeApiCall(
call = {
val yieldBalances = withContext(dispatchers.io) {
stakeKitApi.getMultipleYieldBalances(requests).bind()
}
yieldsBalancesStore.storeActual(userWalletId = params.userWalletId, values = yieldBalances)
},
onError = {
Timber.e(it, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
throw it
},
)
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.data.staking.single
import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor
import com.tangem.data.staking.fetcher.commonFetcher
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating StakingID
* @property stakeKitApi stake kit API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleYieldBalanceFetcher,
FlowFetcher<YieldBalanceFetcherParams.Single> by commonFetcher(
implementor = createSingleFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers),
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
private fun createSingleFetcherImplementor(
yieldsBalancesStore: YieldsBalancesStore,
stakingIdFactory: StakingIdFactory,
stakeKitApi: StakeKitApi,
dispatchers: CoroutineDispatcherProvider,
): YieldBalanceFetcherImplementor<YieldBalanceFetcherParams.Single> {
return SingleYieldBalanceFetcherImplementor(
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,72 @@
package com.tangem.data.staking.single
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
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 [SingleYieldBalanceProducer]
*
* @property params params
* @property multiYieldBalanceSupplier multi yield balance supplier
* @property stakingIdFactory factory for creating [StakingID]
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
@Assisted private val params: SingleYieldBalanceProducer.Params,
private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier,
private val stakingIdFactory: StakingIdFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleYieldBalanceProducer {
override val fallback: YieldBalance by lazy {
YieldBalance.Error(
integrationId = stakingIdFactory.createIntegrationId(currencyId = params.currencyId),
address = null,
)
}
override fun produce(): Flow<YieldBalance> {
return multiYieldBalanceSupplier(
params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId),
)
.mapNotNull {
val currentStakingIds = stakingIdFactory.create(
userWalletId = params.userWalletId,
currencyId = params.currencyId,
network = params.network,
)
it.firstOrNull { balance ->
val integrationId = balance.integrationId
val address = balance.address
if (integrationId == null || address == null) return@mapNotNull null
val balanceStakingId = StakingID(integrationId = integrationId, address = address)
currentStakingIds.contains(balanceStakingId)
}
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : SingleYieldBalanceProducer.Factory {
override fun create(params: SingleYieldBalanceProducer.Params): DefaultSingleYieldBalanceProducer
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.staking.single
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Implementor of fetcher for refreshing single yield balance
*
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating [StakingID]
* @property stakeKitApi StakeKit API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class SingleYieldBalanceFetcherImplementor(
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldBalanceFetcherImplementor<YieldBalanceFetcherParams.Single> {
override suspend fun createStakingIds(params: YieldBalanceFetcherParams.Single): Set<StakingID> {
val dataStakingId = stakingIdFactory.createForDefault(
userWalletId = params.userWalletId,
currencyId = params.currencyId,
network = params.network,
) ?: return emptySet()
return setOf(
StakingID(integrationId = dataStakingId.integrationId, address = dataStakingId.address),
)
}
override suspend fun fetch(
params: YieldBalanceFetcherParams.Single,
stakingIds: Set<StakingID>,
requests: List<YieldBalanceRequestBody>,
) {
fetchInternal(
params = params,
stakingId = stakingIds.first(),
request = requests.first(),
)
}
private suspend fun fetchInternal(
params: YieldBalanceFetcherParams.Single,
stakingId: StakingID,
request: YieldBalanceRequestBody,
) {
safeApiCall(
call = {
val result = withContext(dispatchers.io) {
stakeKitApi.getSingleYieldBalance(
integrationId = stakingId.integrationId,
body = request,
).bind()
}
yieldsBalancesStore.storeActual(
userWalletId = params.userWalletId,
values = setOf(
YieldBalanceWrapperDTO(
balances = result,
integrationId = request.integrationId,
addresses = request.addresses,
),
),
)
},
onError = {
Timber.e(it, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = setOf(stakingId))
throw it
},
)
}
}

View file

@ -0,0 +1,159 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
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.StakingID
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 getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? {
return runtimeStore.getSyncOrNull()
?.get(userWalletId)
?.firstOrNull { stakingId == it.getStakingId() }
}
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
return runtimeStore.getSyncOrNull()?.get(userWalletId)
}
override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) {
refresh(userWalletId = userWalletId, stakingIds = setOf(stakingId))
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) {
it.copySealed(source = StatusSource.CACHE)
}
}
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, stakingIds: Set<StakingID>) {
updateInRuntime(
userWalletId = userWalletId,
stakingIds = stakingIds,
ifNotFound = ::createErrorYieldBalance,
update = { 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.getStakingId() == new.getStakingId() }
?: 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.getStakingId() == new.getStakingId() }
?: values
}
}
}
private suspend fun updateInRuntime(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
ifNotFound: (StakingID) -> YieldBalance? = { null },
update: (YieldBalance) -> YieldBalance,
) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val portfolioBalances = stored[userWalletId].orEmpty()
val balances = stakingIds.mapNotNullTo(hashSetOf()) { stakingId ->
val balance = portfolioBalances
.firstOrNull { stakingId == it.getStakingId() }
?: ifNotFound(stakingId)
?: return@mapNotNullTo null
update(balance)
}
val updatedBalances = portfolioBalances.addOrReplace(items = balances) { old, new ->
old.getStakingId() == new.getStakingId()
}
put(key = userWalletId, value = updatedBalances)
}
}
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance {
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
}
private fun YieldBalanceWrapperDTO.getStakingId(): StakingID? {
val integrationId = integrationId
val address = addresses.address
if (integrationId == null || address.isBlank()) return null
return StakingID(integrationId = integrationId, address = address)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.model.StakingID
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>>
/** Get [YieldBalance] by [userWalletId] and [stakingId] synchronously or null */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance?
/** Get all [YieldBalance] by [userWalletId] synchronously or null */
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): 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 [stakingIds] */
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -0,0 +1,89 @@
package com.tangem.data.staking.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toMigratedCoinId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import javax.inject.Inject
/**
* Factory of [StakingID]
*
* @property walletManagersFacade wallet manager facade
*
[REDACTED_AUTHOR]
*/
internal class StakingIdFactory @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): Set<StakingID> {
val addresses = walletManagersFacade.getAddresses(userWalletId = userWalletId, network = network)
val integrationId = createIntegrationId(currencyId) ?: return emptySet()
return addresses.mapTo(hashSetOf()) { address ->
StakingID(integrationId = integrationId, address = address.value)
}
}
suspend fun createForDefault(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
network: Network,
): StakingID? {
val address = walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
val integrationId = createIntegrationId(currencyId)
if (address == null || integrationId == null) return null
return StakingID(integrationId = integrationId, address = address)
}
fun createIntegrationId(currencyId: CryptoCurrency.ID): String? {
val integrationKey = with(currencyId) { rawNetworkId.plus(rawCurrencyId) }
return integrationIdMap[integrationKey]
}
@Suppress("UnusedPrivateMember", "unused")
companion object {
private const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking"
private const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking"
private const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking"
private const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking"
private const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking"
private const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking"
private const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking"
private const val TRON_INTEGRATION_ID = "tron-trx-native-staking"
private const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking"
private const val KAVA_INTEGRATION_ID = "kava-kava-native-staking"
private const val NEAR_INTEGRATION_ID = "near-near-native-staking"
private const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking"
private const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking"
// uncomment items as implementation is ready
val integrationIdMap = mapOf(
Blockchain.TON.toDefaultKey() to TON_INTEGRATION_ID,
Blockchain.Solana.toDefaultKey() to SOLANA_INTEGRATION_ID,
Blockchain.Cosmos.toDefaultKey() to COSMOS_INTEGRATION_ID,
Blockchain.Tron.toDefaultKey() to TRON_INTEGRATION_ID,
Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
// Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
Blockchain.BSC.toDefaultKey() to BINANCE_INTEGRATION_ID,
// Blockchain.Polkadot.toDefaultKey() to POLKADOT_INTEGRATION_ID,
// Blockchain.Avalanche.toDefaultKey() to AVALANCHE_INTEGRATION_ID,
// Blockchain.Cronos.toDefaultKey() to CRONOS_INTEGRATION_ID,
// Blockchain.Kava.toDefaultKey() to KAVA_INTEGRATION_ID,
// Blockchain.Near.toDefaultKey() to NEAR_INTEGRATION_ID,
// Blockchain.Tezos.toDefaultKey() to TEZOS_INTEGRATION_ID,
Blockchain.Cardano.toDefaultKey() to CARDANO_INTEGRATION_ID,
)
private fun Blockchain.toDefaultKey(): String = id + toCoinId()
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.staking.utils
import com.tangem.datasource.api.stakekit.models.request.Address
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.domain.staking.model.StakingID
/**
* Factory for creating [YieldBalanceRequestBody]
*
[REDACTED_AUTHOR]
*/
internal object YieldBalanceRequestBodyFactory {
fun create(stakingID: StakingID): YieldBalanceRequestBody {
return YieldBalanceRequestBody(
addresses = Address(
address = stakingID.address,
additionalAddresses = null, // todo fill additional addresses metadata if needed
explorerUrl = "", // todo fill exporer url [REDACTED_JIRA]
),
args = YieldBalanceRequestBody.YieldBalanceRequestArgs(
validatorAddresses = listOf(), // todo add validators [REDACTED_JIRA]
),
integrationId = stakingID.integrationId,
)
}
}

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,400 @@
package com.tangem.data.staking.multi
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val stakeKitApi: StakeKitApi = mockk()
private val fetcher = DefaultMultiYieldBalanceFetcher(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `fetch yields balances successfully`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = tonAndSolanaIds.map(YieldBalanceRequestBodyFactory::create).sortedBy { it.integrationId }
val result = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId),
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId),
)
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result)
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) }
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch yields balances failure if user wallet is not supported`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
val actual = fetcher(params)
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if userWalletsStore returns null`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null
val actual = fetcher(params)
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingIdFactory returns empty list`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet()
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet()
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
}
coVerify(inverse = true) {
yieldsBalancesStore.refresh(any(), any<Set<StakingID>>())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(any(), any())
yieldsBalancesStore.storeError(any(), any())
}
val expected = IllegalStateException("Unable to create staking ids for $params: list is empty")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if yields converting is failed`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(
MockYieldDTOFactory.create(tonId).copy(id = null),
MockYieldDTOFactory.create(solanaId).copy(id = null),
)
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(solanaId, tonId).joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1")))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(solanaId, tonId).joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = setOf(solanaId, tonId).map(YieldBalanceRequestBodyFactory::create)
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException)
as ApiResponse<Set<YieldBalanceWrapperDTO>>
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns errorResponse
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
}
coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) }
val expected = ApiResponseError.NetworkException
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
private companion object {
val userWalletId = UserWalletId("011")
val userWallet = MockUserWalletFactory.create()
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val solana = mocks.createCoin(Blockchain.Solana)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
val tonAndSolanaIds = setOf(tonId, solanaId)
}
}

View file

@ -0,0 +1,191 @@
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.StakingID
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 = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
}
}

View file

@ -0,0 +1,444 @@
package com.tangem.data.staking.single
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val stakeKitApi: StakeKitApi = mockk()
private val fetcher = DefaultSingleYieldBalanceFetcher(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `fetch yields balances successfully`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val request = YieldBalanceRequestBodyFactory.create(tonId)
val result = listOf(createBalanceDTO())
coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns ApiResponse.Success(result)
val values = result.mapTo(hashSetOf()) { it.toWrapper(request) }
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = tonId.integrationId, body = request)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values)
}
coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) }
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch yields balances failure if user wallet is not supported`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
val actual = fetcher(params)
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if userWalletsStore returns null`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null
val actual = fetcher(params)
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingIdFactory createForDefault returns null`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns null
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(userWalletId = userWalletId, currencyId = ton.id, network = ton.network)
}
coVerify(inverse = true) {
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Unable to create staking ids for $params: list is empty")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, setOf(tonId))
}
coVerify(inverse = true) {
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, setOf(tonId))
}
coVerify(inverse = true) {
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if yields converting is failed`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId).copy(id = null))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, setOf(tonId))
}
coVerify(inverse = true) {
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(tonId).joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1")))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, setOf(tonId))
}
coVerify(inverse = true) {
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(tonId).joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
val params = YieldBalanceFetcherParams.Single(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
} just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val request = YieldBalanceRequestBodyFactory.create(tonId)
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<List<BalanceDTO>>
coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns errorResponse
coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs
val actual = fetcher(params)
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId))
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(tonId.integrationId, request)
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(tonId))
}
coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) }
val expected = ApiResponseError.NetworkException
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
private fun createBalanceDTO(): BalanceDTO {
return BalanceDTO(
groupId = "dictas",
type = BalanceDTO.BalanceTypeDTO.REWARDS,
amount = BigDecimal.ONE,
date = null,
pricePerShare = BigDecimal.ONE,
pendingActions = listOf(),
pendingActionConstraints = listOf(),
tokenDTO = TokenDTO(
name = "Casandra Paul",
network = NetworkTypeDTO.POLYGON,
symbol = "vim",
decimals = 3994,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
validatorAddress = null,
validatorAddresses = listOf(),
providerId = null,
)
}
private fun BalanceDTO.toWrapper(request: YieldBalanceRequestBody): YieldBalanceWrapperDTO {
return YieldBalanceWrapperDTO(
balances = listOf(this),
integrationId = request.integrationId,
addresses = request.addresses,
)
}
private companion object {
val userWalletId = UserWalletId("011")
val userWallet = MockUserWalletFactory.create()
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
}
}

View file

@ -0,0 +1,239 @@
package com.tangem.data.staking.single
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.toDomain
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.wallets.models.UserWalletId
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 DefaultSingleYieldBalanceProducerTest {
private val params = SingleYieldBalanceProducer.Params(
userWalletId = UserWalletId(stringValue = "011"),
currencyId = ton.id,
network = ton.network,
)
private val multiNetworkStatusSupplier = mockk<MultiYieldBalanceSupplier>()
private val stakingIdFactory = mockk<StakingIdFactory>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultSingleYieldBalanceProducer(
params = params,
stakingIdFactory = stakingIdFactory,
multiYieldBalanceSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
)
@Test
fun `test that flow is mapped for data from params`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val expected = flowOf(
setOf(
balance,
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
),
)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
val actual = producer.produce()
verify { multiNetworkStatusSupplier(multiParams) }
val values = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(balance))
}
@Test
fun `test that flow is updated if yield balance is updated`() = runTest {
val expected = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
val actual = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
expected.emit(value = setOf(balance))
val values1 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(balance))
// second emit
val updatedStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = tonId.address)
expected.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(balance, updatedStatus))
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val expected = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
val actual = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
expected.emit(value = setOf(balance))
val values1 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(balance))
// second emit
expected.emit(value = setOf(balance))
val values2 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balance))
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val innerFlow = MutableStateFlow(value = false)
val expected = flow {
if (innerFlow.value) {
emit(setOf(balance))
} else {
throw exception
}
}
.buffer(capacity = 5)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
every { stakingIdFactory.createIntegrationId(currencyId = params.currencyId) } returns tonId.integrationId
val actual = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
val values1 = getEmittedValues(flow = actual)
coVerify(inverse = true) { stakingIdFactory.create(any(), any(), any()) }
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = null)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balance))
}
@Test
fun `test if flow doesn't contain network from params`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain()
val expected = flowOf(setOf(balance))
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
val actual = producer.produce()
verify { multiNetworkStatusSupplier(multiParams) }
val values = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values.size).isEqualTo(0)
}
@Test
fun `test if wallet manager facade returns empty set`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val expected = flowOf(setOf(balance))
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns emptySet()
val actual = producer.produce()
verify { multiNetworkStatusSupplier(multiParams) }
val values = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values.size).isEqualTo(0)
}
private companion object {
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
val stakingIds = setOf(tonId)
}
}

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,178 @@
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.StakingID
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 emptySet<YieldBalance>())
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 emptySet<YieldBalance>())
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, stakingIds = setOf(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, stakingIds = setOf(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,
StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
),
)
}
}

View file

@ -618,7 +618,7 @@ internal class DefaultCurrenciesRepository(
}
coroutineScope {
launch { expressServiceLoader.update(userWalletId, tokens) }
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
}
}
@ -639,7 +639,7 @@ internal class DefaultCurrenciesRepository(
skipCache = refresh,
block = {
coroutineScope {
launch { expressServiceLoader.update(userWalletId, tokens) }
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
}
},
)
@ -677,13 +677,13 @@ internal class DefaultCurrenciesRepository(
isSortedByBalance = false,
)
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find a user wallet with provided ID: $userWalletId"
}
}
private suspend fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) {
private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected)

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,15 @@ 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.blockchain.nft.models.NFTAsset
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,8 +30,8 @@ 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
@Suppress("LargeClass")
internal class DefaultTransactionRepository(
private val walletManagersFacade: WalletManagersFacade,
private val walletManagersStore: WalletManagersStore,
@ -39,13 +40,12 @@ internal class DefaultTransactionRepository(
override suspend fun createTransaction(
amount: Amount,
fee: Fee,
fee: Fee?,
memo: String?,
destination: String,
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,44 +54,83 @@ internal class DefaultTransactionRepository(
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found")
return@withContext walletManager.createTransactionDataInternal(
val extras = txExtras ?: getMemoExtras(networkId = network.id.value, memo)
return@withContext if (fee != null) {
walletManager.createTransaction(
amount = amount,
fee = fee,
destination = destination,
).copy(
extras = extras,
)
} else {
TransactionData.Uncompiled(
amount = amount,
sourceAddress = walletManager.wallet.address,
destinationAddress = destination,
extras = extras,
fee = null,
)
}
}
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 = memo,
memo = null,
destination = destination,
userWalletId = userWalletId,
network = network,
txExtras = txExtras,
hash = hash,
txExtras = getMemoExtras(networkId = network.id.value, memo = memo) ?: extras,
)
}
override suspend fun createApprovalTransaction(
amount: Amount,
approvalAmount: Amount?,
fee: Fee,
fee: Fee?,
contractAddress: String,
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 +143,53 @@ internal class DefaultTransactionRepository(
userWalletId = userWalletId,
network = network,
txExtras = extras,
hash = hash,
)
}
override suspend fun createNFTTransferTransaction(
ownerAddress: String,
nftAsset: NFTAsset,
fee: Fee?,
memo: String?,
destinationAddress: String,
userWalletId: UserWalletId,
network: Network,
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val nftTransferCallData = SmartContractCallDataProviderFactory.getNFTTransferCallData(
destinationAddress = destinationAddress,
ownerAddress = ownerAddress,
nftAsset = nftAsset,
blockchain = blockchain,
)
val extras = if (nftTransferCallData != null) {
createTransactionDataExtras(
callData = nftTransferCallData,
network = network,
nonce = null,
gasLimit = null,
)
} else {
null
}
return@withContext createTransaction(
amount = Amount(
value = nftAsset.amount?.toBigDecimal() ?: error("Invalid amount"),
token = Token(
symbol = blockchain.currency,
contractAddress = "",
decimals = nftAsset.decimals ?: error("Invalid decimals"),
),
),
fee = fee,
memo = memo,
destination = destinationAddress,
userWalletId = userWalletId,
network = network,
txExtras = extras,
)
}
@ -115,9 +200,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 +211,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 +258,8 @@ internal class DefaultTransactionRepository(
}
override fun createTransactionDataExtras(
data: String,
callData: SmartContractCallData,
network: Network,
transactionType: TransactionType,
nonce: BigInteger?,
gasLimit: BigInteger?,
): TransactionExtras {
@ -189,15 +268,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 +304,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 +331,46 @@ internal class DefaultTransactionRepository(
else -> null
}
}
override suspend fun prepareForSend(
transactionData: TransactionData,
signer: TransactionSigner,
userWalletId: UserWalletId,
network: Network,
): Result<ByteArray> = withContext(coroutineDispatcherProvider.io) {
val preparer = getPreparer(network, userWalletId)
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)
}
}
override suspend fun prepareForSendMultiple(
transactionData: List<TransactionData>,
signer: TransactionSigner,
userWalletId: UserWalletId,
network: Network,
): Result<List<ByteArray>> = withContext(coroutineDispatcherProvider.io) {
val preparer = getPreparer(network, userWalletId)
when (val prepareForSend = preparer.prepareForSendMultiple(transactionData, signer)) {
is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error)
is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data)
}
}
private suspend fun getPreparer(network: Network, userWalletId: UserWalletId): TransactionPreparer {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
val preparer = walletManager as? TransactionPreparer ?: run {
Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder")
error("Wallet manager does not support TransactionPreparer")
}
return preparer
}
}

View file

@ -1,13 +1,18 @@
package com.tangem.data.visa
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.data.visa.converter.AccessCodeDataConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverterWithState
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest
import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
@ -25,70 +30,48 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val visaAuthRepository: VisaAuthRepository,
private val visaLibLoader: VisaLibLoader,
private val apiConfigsManager: ApiConfigsManager,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
request = ActivationStatusRequest(
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
),
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
VisaActivationStatusConverterWithState.convert(result)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatusLongPoll(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCardWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
customerWalletAddress = request.customerWalletAddress,
request = GetCardWalletAcceptanceRequest(
customerWalletAddress = request.activationOrderInfo.customerWalletAddress,
cardWalletAddress = request.cardWalletAddress,
),
).getOrThrow()
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
hashToSign = result.result.hash,
)
}
@ -98,20 +81,19 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCustomerWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
cardWalletAddress = request.cardWalletAddress,
request = GetCustomerWalletAcceptanceRequest(
cardWalletAddress = request.cardWalletAddress,
customerWalletAddress = request.customerWalletAddress,
),
).getOrThrow()
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
hashToSign = result.result.hash,
)
}
@ -120,27 +102,22 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCardWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCardWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCardWalletRequest.Data(
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
deployAcceptanceSignature = signedData.signature,
),
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
orderId = signedData.dataToSign.request.activationOrderInfo.orderId,
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.dataToSign.request.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
),
deployAcceptanceSignature = signedData.signature,
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
),
)
).getOrThrow()
}
}
}
@ -150,22 +127,17 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCustomerWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCustomerWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCustomerWalletRequest.Data(
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
address = signedData.customerWalletAddress,
deployAcceptanceSignature = signedData.signature,
),
orderId = signedData.dataToSign.request.orderId,
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
deployAcceptanceSignature = signedData.signature,
customerWalletAddress = signedData.customerWalletAddress,
),
),
)
).getOrThrow()
}
}
}
@ -175,27 +147,29 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = pinCode.activationOrderId,
data = SetPinCodeRequest.Data(
sessionKey = pinCode.sessionId,
iv = pinCode.iv,
encryptedPin = pinCode.encryptedPin,
),
orderId = pinCode.activationOrderId,
sessionId = pinCode.sessionId,
iv = pinCode.iv,
pin = pinCode.encryptedPin,
),
)
).getOrThrow()
}
}
}
override suspend fun getPinCodeRsaEncryptionPublicKey(): String {
return visaLibLoader.getOrCreateConfig().rsaPublicKey
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemVisa).environment
val rsaPublicKey = visaLibLoader.getOrCreateConfig().rsaPublicKey
return when (env) {
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
-> rsaPublicKey.dev
ApiEnvironment.PROD -> rsaPublicKey.prod
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {

View file

@ -1,8 +1,8 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
@ -14,80 +14,86 @@ import javax.inject.Inject
@Suppress("UnusedPrivateMember")
internal class DefaultVisaAuthRepository @Inject constructor(
private val visaAuthApi: TangemVisaAuthApi,
private val visaAuthApi: TangemVisaApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRepository {
override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByCard(
// cardId = cardId,
// cardPublicKey = cardPublicKey,
// )
//
// VisaAuthChallenge.Card(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
val response = visaAuthApi.generateNonceByCardId(
GenerateNoneByCardIdRequest(
cardId = cardId,
cardPublicKey = cardPublicKey,
),
)
VisaAuthChallenge.Card(
challenge = CryptoUtils.generateRandomBytes(length = 16).toHexString(),
session = VisaAuthSession("session"),
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
val response = visaAuthApi.generateNonceByCardWallet(
GenerateNoneByCardWalletRequest(
cardWalletAddress = cardWalletAddress,
),
)
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {
// val response = when (signedChallenge) {
// is VisaAuthSignedChallenge.ByCardPublicKey -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = signedChallenge.salt,
// )
// }
// is VisaAuthSignedChallenge.ByWallet -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = null,
// )
// }
// }
//
// VisaAuthTokens(
// accessToken = response.accessToken,
// refreshToken = response.refreshToken,
// )
val response = when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> {
visaAuthApi.getAccessTokenByCardId(
GetAccessTokenByCardIdRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
salt = signedChallenge.salt,
),
)
}
is VisaAuthSignedChallenge.ByWallet -> {
visaAuthApi.getAccessTokenByCardWallet(
GetAccessTokenByCardWalletRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
),
)
}
}
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("refreshToken"),
accessToken = response.result.accessToken,
refreshToken = VisaAuthTokens.RefreshToken(
value = response.result.refreshToken,
authType = when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> VisaAuthTokens.RefreshToken.Type.CardId
is VisaAuthSignedChallenge.ByWallet -> VisaAuthTokens.RefreshToken.Type.CardWallet
},
),
)
}
override suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens =
withContext(dispatchers.io) {
// TODO
val response = when (refreshToken.authType) {
VisaAuthTokens.RefreshToken.Type.CardId ->
visaAuthApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
VisaAuthTokens.RefreshToken.Type.CardWallet ->
visaAuthApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
}.getOrThrow()
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("new refreshToken"),
accessToken = response.result.accessToken,
refreshToken = refreshToken.copy(value = response.result.refreshToken),
)
}
}

View file

@ -15,18 +15,15 @@ class MockVisaActivationRepository @AssistedInject constructor(
override suspend fun getActivationRemoteState(): VisaActivationRemoteState {
return VisaActivationRemoteState.CardWalletSignatureRequired(
request = VisaCardWalletDataToSignRequest(
activationOrderInfo = VisaActivationOrderInfo(
orderId = "orderId",
customerId = "customerId",
customerWalletAddress = "customerWalletAddress",
cardWalletAddress = null,
),
)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState {
return VisaActivationRemoteState.PaymentAccountDeploying
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet {

View file

@ -12,7 +12,7 @@ internal data class VisaConfig(
@Json(name = "txHistoryAPIAdditionalHeaders")
val header: Header,
@Json(name = "rsaPublicKey")
val rsaPublicKey: String,
val rsaPublicKey: RsaPublicKey,
) {
@JsonClass(generateAdapter = true)
@ -28,4 +28,10 @@ internal data class VisaConfig(
@Json(name = "x-asn")
val xAsn: String,
)
@JsonClass(generateAdapter = true)
data class RsaPublicKey(
@Json(name = "prod") val prod: String,
@Json(name = "dev") val dev: String,
)
}

View file

@ -1,15 +0,0 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import javax.inject.Inject
class VisaActivationStatusConverter @Inject constructor() :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// TODO Will be implemented in the future
return VisaActivationRemoteState.Activated
}
}

View file

@ -0,0 +1,81 @@
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
private const val PIN_CODE_VALIDATION_ERROR = 1000
object VisaActivationStatusConverterWithState :
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)
// TODO [REDACTED_TASK_KEY]
val result = value.result
if (result.status == Status.PinCodeRequired.stringValue && result.stepChangeCode == PIN_CODE_VALIDATION_ERROR) {
return if (lastUpdatedAt.value != result.updatedAt) {
lastUpdatedAt.value = result.updatedAt
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
)
} else {
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
)
}
}
val status = Status.entries.find { it.stringValue == result.status } ?: error(
"Unknown status: ${result.status}",
)
return when (status) {
Status.Activated -> VisaActivationRemoteState.Activated
Status.Failed -> VisaActivationRemoteState.Failed
Status.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation
Status.CardWalletSignatureRequired ->
VisaActivationRemoteState.CardWalletSignatureRequired(
activationOrderInfo = result.activationOrder!!.convert(),
)
Status.CustomerWalletSignatureRequired ->
VisaActivationRemoteState.CustomerWalletSignatureRequired(
activationOrderInfo = result.activationOrder!!.convert(),
)
Status.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying
Status.PinCodeRequired -> VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode,
)
Status.WaitingForActivation -> VisaActivationRemoteState.WaitingForActivationFinishing
}
}
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
return VisaActivationOrderInfo(
orderId = id,
customerId = customerId,
customerWalletAddress = customerWalletAddress,
cardWalletAddress = cardWalletAddress.takeIf { it.isNotBlank() },
)
}
private enum class Status(val stringValue: String) {
Activated("activated"),
Failed("failed"),
BlockedForActivation("blocked_for_activation"),
CardWalletSignatureRequired("card_wallet_signature_required"),
CustomerWalletSignatureRequired("customer_wallet_signature_required"),
PaymentAccountDeploying("payment_account_deploying"),
PinCodeRequired("pin_code_required"),
WaitingForActivation("waiting_for_activation"),
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.visa.di
import com.tangem.data.visa.DefaultVisaActivationRepository
import com.tangem.data.visa.DefaultVisaAuthRepository
import com.tangem.data.visa.MockVisaRepository
import com.tangem.data.visa.MockVisaActivationRepository
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.repository.VisaRepository
@ -20,19 +20,19 @@ internal interface VisaDataModule {
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: DefaultVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// Mocked
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: MockVisaActivationRepository.Factory,
repository: DefaultVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
// Mocked
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: MockVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// @Binds
// fun bindVisaRepository(repository: DefaultVisaRepository): VisaRepository

View file

@ -5,7 +5,8 @@ import com.tangem.data.visa.model.AccessCodeData
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardWalletRequest
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
@ -23,7 +24,7 @@ typealias VisaAuthorizationHeader = String
internal class VisaApiRequestMaker @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val visaAuthApi: TangemVisaAuthApi,
private val visaAuthApi: TangemVisaApi,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val dispatcherProvider: CoroutineDispatcherProvider,
) {
@ -79,16 +80,18 @@ internal class VisaApiRequestMaker @Inject constructor(
}
private suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens {
val result = visaAuthApi.refreshAccessToken(refreshToken.value).getOrThrow()
val result = visaAuthApi.refreshCardWalletAccessToken(
RefreshTokenByCardWalletRequest(refreshToken = refreshToken.value),
).getOrThrow()
return VisaAuthTokens(
accessToken = result.accessToken,
refreshToken = VisaAuthTokens.RefreshToken(result.refreshToken),
accessToken = result.result.accessToken,
refreshToken = refreshToken.copy(value = result.result.refreshToken),
)
}
@Throws
private suspend fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens {
private fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens {
val userWallet = findVisaUserWallet(userWalletId)
val status = userWallet.scanResponse.visaCardActivationStatus ?: error("Visa card activation status not found")
@ -99,7 +102,7 @@ internal class VisaApiRequestMaker @Inject constructor(
return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated")
}
private suspend fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet {
private fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"No user wallet found: $userWalletId"
}

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,14 @@ dependencies {
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(projects.domain.blockaid)
implementation(projects.domain.blockaid.models)
implementation(projects.libs.crypto)
/* Tests */
testImplementation(projects.common.test)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
}

View file

@ -5,31 +5,32 @@ 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.*
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.request.DefaultWcRequestUseCaseFactory
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.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
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.disconnect.WcDisconnectUseCase
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -58,29 +59,25 @@ 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
@Singleton
fun defaultWcPairUseCase(
sessionsManager: WcSessionsManager,
associateNetworksDelegate: AssociateNetworksDelegate,
caipNamespaceDelegate: CaipNamespaceDelegate,
): DefaultWcPairUseCase = DefaultWcPairUseCase(
sessionsManager = sessionsManager,
associateNetworksDelegate = associateNetworksDelegate,
caipNamespaceDelegate = caipNamespaceDelegate,
)
fun defaultWcPairUseCase(): WcPairService = DefaultWcPairService()
@Provides
@Singleton
fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default
fun wcPairUseCaseFactory(default: DefaultWcPairUseCase.Factory): WcPairUseCase.Factory = default
@Provides
@Singleton
fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate()
@Provides
@Singleton
@ -106,24 +103,15 @@ 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,
scope = scope,
)
}
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
@Provides
@Singleton
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
fun defaultWcRequestService(diHelperBox: DiHelperBox): DefaultWcRequestService {
return DefaultWcRequestService(
requestConverters = diHelperBox.handlers,
)
}
@Provides
@Singleton
@ -131,14 +119,33 @@ internal object WalletConnectDataModule {
@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(
@SdkMoshi moshi: Moshi,
excludedBlockchains: ExcludedBlockchains,
sessionsManager: WcSessionsManager,
factories: WcSolanaNetwork.Factories,
walletManager: UserWalletManager,
): WcSolanaNetwork = WcSolanaNetwork(
moshi = moshi,
sessionsManager = sessionsManager,
factories = factories,
excludedBlockchains = excludedBlockchains,
walletManager = walletManager,
)
@Provides
@Singleton
@ -156,12 +163,10 @@ internal object WalletConnectDataModule {
diHelperBox: DiHelperBox,
getWallets: GetWalletsUseCase,
currenciesRepository: CurrenciesRepository,
excludedBlockchains: ExcludedBlockchains,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = diHelperBox.converters,
getWallets = getWallets,
currenciesRepository = currenciesRepository,
excludedBlockchains = excludedBlockchains,
)
@Provides
@ -169,15 +174,28 @@ internal object WalletConnectDataModule {
fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox(
handlers = setOf(
ethNetwork,
solanaNetwork,
),
converters = setOf(
ethNetwork,
solanaNetwork,
),
converters = buildMap {
ethNetwork.namespaceKey to ethNetwork
solanaNetwork.namespaceKey to solanaNetwork
},
)
@Provides
@Singleton
fun wcRequestUseCaseFactory(diHelperBox: DiHelperBox): WcRequestUseCaseFactory {
return DefaultWcRequestUseCaseFactory(diHelperBox.handlers)
}
@Provides
@Singleton
fun providesWcDisconnectUseCase(sessionsManager: WcSessionsManager): WcDisconnectUseCase {
return WcDisconnectUseCase(sessionsManager)
}
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,25 @@
package com.tangem.data.walletconnect.network.ethereum
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
import com.tangem.domain.walletconnect.usecase.method.WcEthTransaction
import com.tangem.utils.converter.Converter
internal class EthTransactionParamsConverter(
private val context: WcMethodUseCaseContext,
) : Converter<WcEthTransactionParams, WcEthTransaction?> {
override fun convert(value: WcEthTransactionParams): WcEthTransaction? {
val dAppFee = WcEthTxHelper.getDAppFee(context.network, value)
val transactionData = WcEthTxHelper.createTransactionData(
dAppFee = dAppFee,
network = context.network,
txParams = value,
)
transactionData ?: return null
return WcEthTransaction(
dAppFee = dAppFee,
transactionData = transactionData,
)
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.extensions.isAscii
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper.prepareToSendMessageData
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.method.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 WcEthMessageSignUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcEthMethod.MessageSign,
private val walletManagersFacade: WalletManagersFacade,
private val signUseCase: SignUseCase,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Nothing, WcMessageSignUseCase.SignModel>(),
WcMessageSignUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
override suspend fun SignCollector<WcMessageSignUseCase.SignModel>.onSign(
state: WcSignState<WcMessageSignUseCase.SignModel>,
) {
val hashToSign = LegacySdkHelper.createMessageData(method.rawMessage)
val userWallet = session.wallet
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: return
val signedHash = signUseCase(hashToSign, userWallet, network)
.onLeft { emit(state.toResult(it.left())) }
.getOrNull() ?: return
val respond = prepareToSendMessageData(signedHash, hashToSign, walletManager)
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(wcRespondResult))
}
override fun invoke(): Flow<WcSignState<WcMessageSignUseCase.SignModel>> = flow {
val model = WcMessageSignUseCase.SignModel(humanMsg = method.humanMsg)
emitAll(delegate(model))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): WcEthMessageSignUseCase
}
}
object LegacySdkHelper {
private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
internal fun prepareToSendMessageData(
signedHash: ByteArray,
hashToSign: ByteArray,
walletManager: WalletManager,
): String = UnmarshalHelper.unmarshalSignatureExtended(
signature = signedHash,
hash = hashToSign,
publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(),
).asRSVLegacyEVM().toHexString()
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

@ -2,62 +2,106 @@ 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.WcEthMethodName
import com.tangem.domain.walletconnect.model.WcEthSignTypedDataParams
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.method.WcMethodUseCase
import com.tangem.domain.wallets.models.UserWallet
import jakarta.inject.Inject
internal class WcEthNetwork(
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 fun toWcMethodName(request: WcSdkSessionRequest): WcEthMethodName? {
val methodKey = request.request.method
val name = WcEthMethodName.entries.find { it.raw == methodKey } ?: return null
return name
}
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)
Name.SignTypeData -> TODO()
Name.SignTypeDataV4 -> TODO()
Name.SignTransaction -> TODO()
Name.SendTransaction -> TODO()
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
val name = toWcMethodName(request) ?: 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 accountAddress = when (method) {
is WcEthMethod.MessageSign -> method.account
is WcEthMethod.SendTransaction -> method.transaction.from
is WcEthMethod.SignTransaction -> method.transaction.from
is WcEthMethod.SignTypedData -> method.account
}
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = network,
accountAddress = accountAddress,
)
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)
is WcEthMethod.SignTypedData -> factories.signTypedData.create(context, method)
}
}
override fun handle(wcRequest: WcRequest<WcMethod>) {
wcRequest as WcRequest<WcEthMethod>
val useCase = when (wcRequest.method) {
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
private fun WcEthMethodName.toMethod(request: WcSdkSessionRequest): WcEthMethod? {
val rawParams = request.request.params
return when (this) {
WcEthMethodName.EthSign,
WcEthMethodName.PersonalSign,
-> parseMessageSign(rawParams)
WcEthMethodName.SignTypeData,
WcEthMethodName.SignTypeDataV4,
-> parseTypeData(rawParams)
WcEthMethodName.SignTransaction,
WcEthMethodName.SendTransaction,
-> moshi.fromJson<List<WcEthTransactionParams>>(rawParams)
?.firstOrNull()
?.let {
if (this == WcEthMethodName.SignTransaction) {
WcEthMethod.SignTransaction(transaction = it)
} else {
WcEthMethod.SendTransaction(transaction = it)
}
}
}
_useCases.trySend(useCase)
}
enum class Name(val raw: String) {
Sign("eth_sign"),
PersonalSign("personal_sign"),
SignTypeData("eth_signTypedData"),
SignTypeDataV4("eth_signTypedData_v4"),
SignTransaction("eth_signTransaction"),
SendTransaction("eth_sendTransaction"),
private fun WcEthMethodName.parseMessageSign(rawParams: String): WcEthMethod.MessageSign? {
val list = moshi.fromJson<List<String>>(rawParams) ?: return null
val accountIndex = if (this == WcEthMethodName.EthSign) 0 else 1
val messageIndex = if (this == WcEthMethodName.EthSign) 1 else 0
val account = list.getOrNull(accountIndex) ?: return null
val message = list.getOrNull(messageIndex) ?: return null
val humanMsg = LegacySdkHelper.hexToAscii(message).orEmpty()
return WcEthMethod.MessageSign(account = account, rawMessage = message, humanMsg = humanMsg)
}
private fun parseTypeData(params: String): WcEthMethod.SignTypedData? {
val account = params.substring(params.indexOf("\"") + 1, params.indexOf("\"", startIndex = 2))
val data = params.substring(params.indexOfFirst { it == '{' }, params.indexOfLast { it == '}' } + 1)
val params = moshi.fromJson<WcEthSignTypedDataParams>(data) ?: return null
return WcEthMethod.SignTypedData(params = params, account = account, dataForSign = data)
}
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
return toNetwork(chainId, wallet, excludedBlockchains)
}
override fun toBlockchain(chainId: CAIP2): Blockchain? {
@ -66,7 +110,8 @@ internal class WcEthNetwork(
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 +119,11 @@ internal class WcEthNetwork(
reference = chainId.toString(),
)
}
internal class Factories @Inject constructor(
val messageSign: WcEthMessageSignUseCase.Factory,
val signTypedData: WcEthSignTypedDataUseCase.Factory,
val sendTransaction: WcEthSendTransactionUseCase.Factory,
val signTransaction: WcEthSignTransactionUseCase.Factory,
)
}

View file

@ -0,0 +1,67 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.formatHex
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
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.method.WcEthTransaction
import com.tangem.domain.walletconnect.usecase.method.WcEthTransactionUseCase
import com.tangem.domain.walletconnect.usecase.method.WcSignState
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 WcEthSendTransactionUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcEthMethod.SendTransaction,
override val respondService: WcRespondService,
private val sendTransaction: SendTransactionUseCase,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Fee, WcEthTransaction>(),
WcEthTransactionUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
private val converter = EthTransactionParamsConverter(context)
override suspend fun SignCollector<WcEthTransaction>.onSign(state: WcSignState<WcEthTransaction>) {
val hash = sendTransaction(state.signModel.transactionData, wallet, network)
.onLeft { error ->
val sendError = IllegalArgumentException(error.toString()) // todo(wc) use domain error
emit(state.toResult(sendError.left()))
}
.getOrNull() ?: return
val respondResult = respondService.respond(rawSdkRequest, hash.formatHex())
emit(state.toResult(respondResult))
}
override fun updateFee(fee: Fee) {
middleAction(fee)
}
override fun invoke(): Flow<WcSignState<WcEthTransaction>> = flow {
val ethTransaction = converter.convert(method.transaction) ?: return@flow
emitAll(delegate.invoke(ethTransaction))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.SendTransaction): WcEthSendTransactionUseCase
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.formatHex
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
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.method.WcEthTransaction
import com.tangem.domain.walletconnect.usecase.method.WcEthTransactionUseCase
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
internal class WcEthSignTransactionUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
private val prepareForSend: PrepareForSendUseCase,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcEthMethod.SignTransaction,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Fee, WcEthTransaction>(),
WcEthTransactionUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
private val converter = EthTransactionParamsConverter(context)
override suspend fun SignCollector<WcEthTransaction>.onSign(state: WcSignState<WcEthTransaction>) {
val hash = prepareForSend(state.signModel.transactionData, wallet, network)
.map { it.toHexString().formatHex() }
.onLeft { error ->
emit(state.toResult(error.left()))
}
.getOrNull()
?: return
val respondResult = respondService.respond(rawSdkRequest, hash)
emit(state.toResult(respondResult))
}
override suspend fun FlowCollector<WcEthTransaction>.onMiddleAction(signModel: WcEthTransaction, fee: Fee) {
val newState = signModel
.copy(transactionData = signModel.transactionData.copy(fee = fee))
emit(newState)
}
override fun updateFee(fee: Fee) {
middleAction(fee)
}
override fun invoke(): Flow<WcSignState<WcEthTransaction>> = flow {
val ethTransaction = converter.convert(method.transaction) ?: return@flow
emitAll(delegate.invoke(ethTransaction))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.SignTransaction): WcEthSignTransactionUseCase
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper.prepareToSendMessageData
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.method.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 WcEthSignTypedDataUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcEthMethod.SignTypedData,
private val walletManagersFacade: WalletManagersFacade,
private val signUseCase: SignUseCase,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Nothing, WcMessageSignUseCase.SignModel>(),
WcMessageSignUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
override suspend fun SignCollector<WcMessageSignUseCase.SignModel>.onSign(
state: WcSignState<WcMessageSignUseCase.SignModel>,
) {
val hashToSign = EthereumUtils.makeTypedDataHash(method.dataForSign)
val userWallet = session.wallet
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: return
val signedHash = signUseCase(hashToSign, userWallet, network)
.onLeft { emit(state.toResult(it.left())) }
.getOrNull() ?: return
val respond = prepareToSendMessageData(signedHash, hashToSign, walletManager)
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(wcRespondResult))
}
override fun invoke(): Flow<WcSignState<WcMessageSignUseCase.SignModel>> = flow {
val model = WcMessageSignUseCase.SignModel(humanMsg = method.humanMsg)
emitAll(delegate(model))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.SignTypedData): WcEthSignTypedDataUseCase
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.data.walletconnect.network.ethereum
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.hexToBigDecimal
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
import java.math.BigDecimal
internal object WcEthTxHelper {
private val MANTLE_FEE_ESTIMATE_MULTIPLIER = BigDecimal("1.8")
fun getDAppFee(network: Network, txParams: WcEthTransactionParams): Fee.Ethereum.Legacy? {
val gasLimit = txParams.gas?.hexToBigDecimal() ?: return null
val gasPrice = txParams.gasPrice?.hexToBigDecimal() ?: return null
val blockchain = Blockchain.fromId(network.id.value)
var feeDecimal = (gasLimit * gasPrice)
.movePointLeft(blockchain.decimals())
if (blockchain == Blockchain.Mantle) {
feeDecimal = feeDecimal.multiply(MANTLE_FEE_ESTIMATE_MULTIPLIER)
}
val feeAmount = Amount(feeDecimal, blockchain)
return Fee.Ethereum.Legacy(feeAmount, gasLimit.toBigInteger(), gasPrice.toBigInteger())
}
fun createTransactionData(
dAppFee: Fee.Ethereum.Legacy?,
network: Network,
txParams: WcEthTransactionParams,
): TransactionData.Uncompiled? {
val destinationAddress = txParams.to ?: return null
val blockchain = Blockchain.fromId(network.id.value)
val value = (txParams.value ?: "0")
.hexToBigDecimal()
.movePointLeft(blockchain.decimals())
val callData = CompiledSmartContractCallData(txParams.data.removePrefix(HEX_PREFIX).hexToBytes())
return TransactionData.Uncompiled(
amount = Amount(value, blockchain),
fee = dAppFee,
sourceAddress = txParams.from,
destinationAddress = destinationAddress,
extras = EthereumTransactionExtras(
callData = callData,
nonce = txParams.nonce?.hexToBigDecimal()?.toBigInteger(),
),
)
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.data.walletconnect.network.solana
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.encodeBase64
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.solana.WcSolanaSignAllTransactionUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import okio.ByteString.Companion.decodeBase64
import org.json.JSONArray
import org.json.JSONObject
internal class DefaultWcSolanaSignAllTransactionUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
private val prepareForSend: PrepareForSendUseCase,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcSolanaMethod.SignAllTransaction,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Nothing, List<TransactionData.Compiled>>(),
WcSolanaSignAllTransactionUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
override suspend fun SignCollector<List<TransactionData.Compiled>>.onSign(
state: WcSignState<List<TransactionData.Compiled>>,
) {
val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
.onLeft { error ->
emit(state.toResult(error.left()))
}
.getOrNull()
?: return
val respond = getSolanaResultTxHashesString(hash)
val respondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(respondResult))
}
override fun invoke(): Flow<WcSignState<List<TransactionData.Compiled>>> {
val transactionData = method.transaction
.map { it.decodeBase64()?.toByteArray() ?: ByteArray(0) }
.map { TransactionData.Compiled(value = TransactionData.Compiled.Data.Bytes(it)) }
return delegate.invoke(transactionData)
}
/**
* Build json object
* {
* "transactions": [
* "signed_tx_hash"
* ]
* }
*/
private fun getSolanaResultTxHashesString(signedHashes: List<ByteArray>): String {
val result = JSONObject()
val transactions = JSONArray()
signedHashes.forEach {
transactions.put(it.encodeBase64())
}
result.put("transactions", transactions)
return result.toString()
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcSolanaMethod.SignAllTransaction,
): DefaultWcSolanaSignAllTransactionUseCase
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.data.walletconnect.network.solana
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.encodeBase58
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.solana.WcSolanaSignTransactionUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import okio.ByteString.Companion.decodeBase64
internal class DefaultWcSolanaSignTransactionUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
private val prepareForSend: PrepareForSendUseCase,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcSolanaMethod.SignTransaction,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<Nothing, TransactionData.Compiled>(),
WcSolanaSignTransactionUseCase {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
)
override suspend fun SignCollector<TransactionData.Compiled>.onSign(state: WcSignState<TransactionData.Compiled>) {
val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
.onLeft { error ->
emit(state.toResult(error.left()))
}
.getOrNull()
?: return
val respond = "{ signature: \"${hash.encodeBase58()}\" }"
val respondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(respondResult))
}
override fun invoke(): Flow<WcSignState<TransactionData.Compiled>> {
val data = method.transaction.decodeBase64()?.toByteArray() ?: ByteArray(0)
val transactionData = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(data),
)
return delegate.invoke(transactionData)
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcSolanaMethod.SignTransaction,
): DefaultWcSolanaSignTransactionUseCase
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.walletconnect.network.solana
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class WcSolanaSignMessageRequest(
@Json(name = "pubkey")
val publicKey: String,
@Json(name = "message")
val message: String,
)
@JsonClass(generateAdapter = true)
internal data class WcSolanaSignTransactionRequest(
@Json(name = "transaction")
val transaction: String,
)

View file

@ -0,0 +1,58 @@
package com.tangem.data.walletconnect.network.solana
import arrow.core.left
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.tangem.blockchain.extensions.decodeBase58
import com.tangem.blockchain.extensions.encodeBase58
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
internal class WcSolanaMessageSignUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcSolanaMethod.SignMessage,
private val signUseCase: SignUseCase,
override val respondService: WcRespondService,
) : BaseWcSignUseCase<Nothing, WcMessageSignUseCase.SignModel>(),
WcMessageSignUseCase {
override val securityStatus: LceFlow<Throwable, CheckTransactionResult> =
emptyFlow() // BlockAid doesn't support solana_signMessage
override suspend fun SignCollector<WcMessageSignUseCase.SignModel>.onSign(
state: WcSignState<WcMessageSignUseCase.SignModel>,
) {
val hashToSign = method.rawMessage.decodeBase58() ?: byteArrayOf()
val userWallet = session.wallet
val signedHash = signUseCase(hashToSign, userWallet, network)
.onLeft { emit(state.toResult(it.left())) }
.getOrNull() ?: return
val respond = "{ signature: \"${signedHash.encodeBase58()}\" }"
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(wcRespondResult))
}
override fun invoke(): Flow<WcSignState<WcMessageSignUseCase.SignModel>> {
return delegate.invoke(initModel = WcMessageSignUseCase.SignModel(method.humanMsg))
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcSolanaMethod.SignMessage): WcSolanaMessageSignUseCase
}
}

View file

@ -1,13 +1,71 @@
package com.tangem.data.walletconnect.network.solana
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.extensions.decodeBase58
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
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.tokens.model.Network
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.model.WcSolanaMethodName
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.method.WcMethodUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.lib.crypto.UserWalletManager
import jakarta.inject.Inject
import timber.log.Timber
internal class WcSolanaNetwork(
private val moshi: Moshi,
private val sessionsManager: WcSessionsManager,
private val factories: Factories,
private val excludedBlockchains: ExcludedBlockchains,
private val walletManager: UserWalletManager,
) : WcNamespaceConverter, WcRequestToUseCaseConverter {
internal class WcSolanaNetwork : WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey("solana")
override fun toWcMethodName(request: WcSdkSessionRequest): WcSolanaMethodName? {
val methodKey = request.request.method
val name = WcSolanaMethodName.entries.find { it.raw == methodKey } ?: return null
return name
}
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
val name = toWcMethodName(request) ?: return null
val method: WcSolanaMethod = name.toMethod(request) ?: return null
val session = sessionsManager.findSessionByTopic(request.topic) ?: return null
val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null
val accountAddress = getAccountAddress(network)
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = network,
accountAddress = accountAddress.orEmpty(),
)
return when (method) {
is WcSolanaMethod.SignMessage -> factories.messageSign.create(context, method)
is WcSolanaMethod.SignTransaction -> factories.signTransaction.create(context, method)
is WcSolanaMethod.SignAllTransaction -> factories.signAllTransaction.create(context, method)
}
}
private suspend fun getAccountAddress(network: Network): String? {
return try {
walletManager.getWalletAddress(network.id.value, network.derivationPath.value)
} catch (exception: Exception) {
Timber.e(exception)
null
}
}
override fun toBlockchain(chainId: CAIP2): Blockchain? {
if (chainId.namespace != namespaceKey.key) return null
return when (chainId.reference) {
@ -17,7 +75,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
@ -30,6 +93,31 @@ internal class WcSolanaNetwork : WcNamespaceConverter {
)
}
private fun WcSolanaMethodName.toMethod(request: WcSdkSessionRequest): WcSolanaMethod? {
val rawParams = request.request.params
return when (this) {
WcSolanaMethodName.SignMessage -> moshi.fromJson<WcSolanaSignMessageRequest>(rawParams)?.let { request ->
val humanMsg = request.message.decodeBase58()?.toHexString().orEmpty()
WcSolanaMethod.SignMessage(
pubKey = request.publicKey,
rawMessage = request.message,
humanMsg = humanMsg,
)
}
WcSolanaMethodName.SignTransaction -> moshi.fromJson<WcSolanaSignTransactionRequest>(rawParams)
?.let { request -> WcSolanaMethod.SignTransaction(request.transaction) }
WcSolanaMethodName.SendAllTransaction -> moshi.fromJson<List<String>>(rawParams)?.let { list ->
WcSolanaMethod.SignAllTransaction(list)
}
}
}
internal class Factories @Inject constructor(
val messageSign: WcSolanaMessageSignUseCase.Factory,
val signTransaction: DefaultWcSolanaSignTransactionUseCase.Factory,
val signAllTransaction: DefaultWcSolanaSignAllTransactionUseCase.Factory,
)
companion object {
private const val MAINNET_CHAIN_ID = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
private const val TESTNET_CHAIN_ID = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z"

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))
if (walletNetwork != null) {
available.add(walletNetwork)
} else {
notAdded.add(WcNetwork.Supported(walletNetwork))
notAdded.add(wcNetwork)
}
}
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)
@ -39,9 +34,14 @@ internal class CaipNamespaceDelegate constructor(
requiredNamespaces[namespaceKey]?.methods?.let { addAll(it) }
optionalNamespaces[namespaceKey]?.methods?.let { addAll(it) }
}
val events = buildSet {
requiredNamespaces[namespaceKey]?.events?.let { addAll(it) }
optionalNamespaces[namespaceKey]?.events?.let { addAll(it) }
}
session.chains.add(account.chainId.raw)
session.accounts.add(account.raw)
session.methods.addAll(methods)
session.events.addAll(events)
}
return result.mapValues { (namespaceKey, session) ->
Wallet.Model.Namespace.Session(

View file

@ -0,0 +1,17 @@
package com.tangem.data.walletconnect.pair
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import javax.inject.Inject
class DefaultWcPairService @Inject constructor() : WcPairService {
private val _pairFlow: Channel<WcPairRequest> = Channel(Channel.BUFFERED)
override val pairFlow: Flow<WcPairRequest> = _pairFlow.receiveAsFlow()
override fun pair(request: WcPairRequest) {
_pairFlow.trySend(request)
}
}

View file

@ -1,57 +1,51 @@
package com.tangem.data.walletconnect.pair
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
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
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.model.WcSessionProposal
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.walletconnect.model.*
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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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")
internal class DefaultWcPairUseCase(
internal class DefaultWcPairUseCase @AssistedInject constructor(
private val sessionsManager: WcSessionsManager,
private val associateNetworksDelegate: AssociateNetworksDelegate,
private val caipNamespaceDelegate: CaipNamespaceDelegate,
) : WcPairUseCase, WcSdkObserver {
private val sdkDelegate: WcPairSdkDelegate,
private val blockAidVerifier: BlockAidVerifier,
@Assisted private val pairRequest: WcPairRequest,
) : 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> {
override operator fun invoke(): Flow<WcPairState> {
val (uri: String, source: WcPairRequest.Source) = pairRequest
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 +56,35 @@ 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(
wallet = sessionForApprove.wallet,
securityStatus = proposalState.dAppSession.securityStatus,
)
sessionsManager.saveSession(newSession)
newSession
}
emit(WcPairState.Approving.Result(sessionForApprove, either))
}
}
@ -112,83 +97,30 @@ 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(
sessionProposal: Wallet.Model.SessionProposal,
): Either<WcPairError, WcPairState.Proposal> = runCatching {
val proposalNetwork = associateNetworksDelegate.associate(sessionProposal)
val verificationInfo = blockAidVerifier.verifyDApp(DAppData(sessionProposal.url)).getOrElse {
Timber.e("Failed to verify DApp: ${it.localizedMessage}")
CheckDAppResult.FAILED_TO_VERIFY
}
val appMetaData = WcAppMetaData(
name = sessionProposal.name,
description = sessionProposal.description,
@ -199,7 +131,7 @@ internal class DefaultWcPairUseCase(
val dAppSession = WcSessionProposal(
dAppMetaData = appMetaData,
proposalNetwork = proposalNetwork,
securityStatus = Any(),
securityStatus = verificationInfo,
)
WcPairState.Proposal(dAppSession)
}.fold(onSuccess = { it.right() }, onFailure = {
@ -209,13 +141,21 @@ internal class DefaultWcPairUseCase(
}
},)
private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession(
userWalletId = walletId,
sdkModel = WcSdkSessionConverter.convert(this),
)
private fun Wallet.Model.Session.toDomain(wallet: UserWallet, securityStatus: CheckDAppResult): WcSession {
return WcSession(
wallet = wallet,
sdkModel = WcSdkSessionConverter.convert(this),
securityStatus = securityStatus,
)
}
private sealed interface TerminalAction {
data class Approve(val sessionForApprove: WcSessionApprove) : TerminalAction
data object Reject : TerminalAction
}
@AssistedFactory
interface Factory : WcPairUseCase.Factory {
override fun create(pairRequest: WcPairRequest): DefaultWcPairUseCase
}
}

View file

@ -0,0 +1,96 @@
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.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()
.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()
}

Some files were not shown because too many files have changed in this diff Show more