Updated on 2026-08-14
This commit is contained in:
commit
c8024b166d
656 changed files with 18270 additions and 4656 deletions
1
data/blockaid/.gitignore
vendored
Normal file
1
data/blockaid/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
42
data/blockaid/build.gradle.kts
Normal file
42
data/blockaid/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 class 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 dispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val mapper: BlockAidMapper,
|
||||
) : BlockAidRepository {
|
||||
|
||||
override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult {
|
||||
val response = withContext(dispatcherProvider.io) {
|
||||
api.scanDomain(DomainScanRequest(data.url))
|
||||
}
|
||||
return mapper.mapToDomain(response)
|
||||
}
|
||||
|
||||
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
|
||||
val response = withContext(dispatcherProvider.io) {
|
||||
when (data.params) {
|
||||
is TransactionParams.Evm -> {
|
||||
api.scanJsonRpc(mapper.mapToEvmRequest(data))
|
||||
}
|
||||
is TransactionParams.Solana -> {
|
||||
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapper.mapToDomain(response)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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, dispatcherProvider: CoroutineDispatcherProvider): BlockAidRepository {
|
||||
return DefaultBlockAidRepository(
|
||||
api = api,
|
||||
dispatcherProvider = dispatcherProvider,
|
||||
mapper = BlockAidMapper(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 testDispatcherProvider = TestingCoroutineDispatcherProvider()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockKAnnotations.init(this)
|
||||
repository = DefaultBlockAidRepository(api, testDispatcherProvider, 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())))
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest {
|
|||
|
||||
DefaultNetworksStatusesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore, // local mock
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -36,6 +40,8 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
) : NFTRepository {
|
||||
|
||||
private val networkJobs = ConcurrentHashMap<Network, JobHolder>()
|
||||
|
|
@ -63,38 +69,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(
|
||||
|
|
@ -117,7 +96,7 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
assets.forEach {
|
||||
val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier)
|
||||
val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId)
|
||||
if (price is NFTSalePrice.Error) {
|
||||
if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) {
|
||||
refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier)
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +134,75 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
|
||||
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,
|
||||
|
|
|
|||
1
data/notifications/.gitignore
vendored
Normal file
1
data/notifications/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
46
data/notifications/build.gradle.kts
Normal file
46
data/notifications/build.gradle.kts
Normal 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
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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 setNotificationsEnabledForWallet(walletId: String, enabled: Boolean) =
|
||||
withContext(dispatchers.io) {
|
||||
tangemTechApi.setNotificationsEnabled(
|
||||
walletId = walletId,
|
||||
body = WalletBody(notifyStatus = enabled),
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
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 isNotificationsEnabledForWallet(walletId: String): Boolean = withContext(dispatchers.io) {
|
||||
tangemTechApi.getWalletById(walletId).getOrThrow().notifyStatus
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
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 wallet id and enabled status WHEN setNotificationsEnabledForWallet THEN updates notification status`() =
|
||||
runTest {
|
||||
// GIVEN
|
||||
val walletId = "test-wallet-id"
|
||||
val enabled = true
|
||||
coEvery {
|
||||
tangemTechApi.setNotificationsEnabled(
|
||||
walletId,
|
||||
WalletBody(notifyStatus = enabled),
|
||||
)
|
||||
} returns ApiResponse.Success(Unit)
|
||||
|
||||
// WHEN
|
||||
repository.setNotificationsEnabledForWallet(walletId, enabled)
|
||||
|
||||
// THEN
|
||||
coVerify { tangemTechApi.setNotificationsEnabled(walletId, WalletBody(notifyStatus = enabled)) }
|
||||
}
|
||||
|
||||
@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 WHEN isNotificationsEnabledForWallet THEN returns notification status`() = runTest {
|
||||
// GIVEN
|
||||
val walletId = "test-wallet-id"
|
||||
val expectedStatus = true
|
||||
coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success(
|
||||
WalletResponse(
|
||||
notifyStatus = expectedStatus,
|
||||
name = "Test Wallet",
|
||||
id = walletId,
|
||||
),
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = repository.isNotificationsEnabledForWallet(walletId)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(expectedStatus)
|
||||
}
|
||||
|
||||
@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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,14 +336,14 @@ 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()
|
||||
|
|
@ -309,6 +367,11 @@ internal class DefaultOnrampRepository(
|
|||
language = null,
|
||||
theme = getTheme(isDarkTheme),
|
||||
requestId = requestId,
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
).bind()
|
||||
},
|
||||
onError = { e ->
|
||||
|
|
@ -326,7 +389,7 @@ internal class DefaultOnrampRepository(
|
|||
txId = data.txId,
|
||||
quote = quote,
|
||||
onrampDataJson = dataJson,
|
||||
userWalletId = userWalletId,
|
||||
userWalletId = userWallet.walletId,
|
||||
currency = currency,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
residency = country.name,
|
||||
|
|
|
|||
1
data/quotes/.gitignore
vendored
Normal file
1
data/quotes/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
36
data/quotes/build.gradle.kts
Normal file
36
data/quotes/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}" },
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesStoreV2]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceDataStore persistence store
|
||||
* @param dispatchers dispatchers
|
||||
*/
|
||||
internal class DefaultQuotesStoreV2(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
|
||||
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : QuotesStoreV2 {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedStatuses = persistenceDataStore.data.firstOrNull()
|
||||
|
||||
if (cachedStatuses.isNullOrEmpty()) return@launch
|
||||
|
||||
runtimeStore.store(
|
||||
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
|
||||
|
||||
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
|
||||
|
||||
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
|
||||
storeInRuntimeStore(values = quotes)
|
||||
}
|
||||
launch { storeInPersistenceStore(values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE)
|
||||
}
|
||||
|
||||
private suspend fun updateStatusSourceInRuntime(currenciesIds: Set<CryptoCurrency.RawID>, source: StatusSource) {
|
||||
runtimeStore.update(default = emptySet()) { stored ->
|
||||
val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id ->
|
||||
val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id)
|
||||
|
||||
quote.copySealed(source = source)
|
||||
}
|
||||
|
||||
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
|
||||
runtimeStore.update(default = emptySet()) { saved ->
|
||||
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
|
||||
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreUpdateMethodsTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
private val store = DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.refresh(currenciesIds = currenciesIds)
|
||||
|
||||
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store contains quote with this id`() = runTest {
|
||||
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
|
||||
|
||||
runtimeStore.store(value = setOf(quote))
|
||||
|
||||
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
|
||||
|
||||
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
|
||||
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
),
|
||||
)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
put("BTC", prevStatus)
|
||||
}
|
||||
}
|
||||
|
||||
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
|
||||
|
||||
store.storeActual(values = mapOf("BTC" to newStatus))
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
|
||||
)
|
||||
val persistenceExpected = mapOf("BTC" to newStatus)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.storeError(currenciesIds = currenciesIds)
|
||||
|
||||
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store contains status with this network`() = runTest {
|
||||
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
store.storeError(
|
||||
currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
)
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}" },
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
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 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.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.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import timber.log.Timber
|
||||
|
||||
internal fun <Params : YieldBalanceFetcherParams> commonFetcher(
|
||||
implementor: YieldBalanceFetcherImplementor<Params>,
|
||||
stakingYieldsStore: StakingYieldsStore,
|
||||
yieldsBalancesStore: YieldsBalancesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): FlowFetcher<Params> {
|
||||
return CommonYieldBalanceFetcher(
|
||||
implementor = implementor,
|
||||
stakingYieldsStore = stakingYieldsStore,
|
||||
yieldsBalancesStore = yieldsBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Common implementation of YieldBalanceFetcher
|
||||
*
|
||||
* @param implementor fetcher implementor
|
||||
* @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 stakingYieldsStore: StakingYieldsStore,
|
||||
private val yieldsBalancesStore: YieldsBalancesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : FlowFetcher<Params> {
|
||||
|
||||
override suspend fun invoke(params: Params): Either<Throwable, Unit> {
|
||||
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 { yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
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.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 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 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),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
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.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 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 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),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
package com.tangem.data.staking.multi
|
||||
|
||||
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.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.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 stakingYieldsStore: StakingYieldsStore = mockk()
|
||||
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
|
||||
private val stakingIdFactory: StakingIdFactory = mockk()
|
||||
private val stakeKitApi: StakeKitApi = mockk()
|
||||
|
||||
private val fetcher = DefaultMultiYieldBalanceFetcher(
|
||||
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 { 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 {
|
||||
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 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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
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.data.staking.MockYieldDTOFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
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.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 stakingYieldsStore: StakingYieldsStore = mockk()
|
||||
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
|
||||
private val stakingIdFactory: StakingIdFactory = mockk()
|
||||
private val stakeKitApi: StakeKitApi = mockk()
|
||||
|
||||
private val fetcher = DefaultSingleYieldBalanceFetcher(
|
||||
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 { 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 {
|
||||
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 stakingIdFactory createForDefault returns null`() = runTest {
|
||||
val params = YieldBalanceFetcherParams.Single(
|
||||
userWalletId = userWalletId,
|
||||
currencyId = ton.id,
|
||||
network = ton.network,
|
||||
)
|
||||
|
||||
coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns null
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerify {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 { 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 {
|
||||
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 mocks = MockCryptoCurrencyFactory()
|
||||
|
||||
val ton = mocks.createCoin(Blockchain.TON)
|
||||
|
||||
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ import com.tangem.blockchain.blockchains.ton.TonTransactionExtras
|
|||
import com.tangem.blockchain.blockchains.tron.TronTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.transaction.TransactionRepository
|
||||
import com.tangem.domain.transaction.models.TransactionType
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -29,7 +29,6 @@ import kotlinx.coroutines.withContext
|
|||
import timber.log.Timber
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType
|
||||
|
||||
internal class DefaultTransactionRepository(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
|
|
@ -45,7 +44,6 @@ internal class DefaultTransactionRepository(
|
|||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
txExtras: TransactionExtras?,
|
||||
hash: String?,
|
||||
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
|
|
@ -54,14 +52,50 @@ internal class DefaultTransactionRepository(
|
|||
derivationPath = network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
|
||||
return@withContext walletManager.createTransactionDataInternal(
|
||||
return@withContext walletManager.createTransaction(
|
||||
amount = amount,
|
||||
fee = fee,
|
||||
memo = memo,
|
||||
destination = destination,
|
||||
).copy(
|
||||
extras = txExtras ?: getMemoExtras(networkId = network.id.value, memo),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createTransferTransaction(
|
||||
amount: Amount,
|
||||
fee: Fee,
|
||||
memo: String?,
|
||||
destination: String,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
|
||||
val callData = SmartContractCallDataProviderFactory.getTokenTransferCallData(
|
||||
destinationAddress = destination,
|
||||
amount = amount,
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
val extras = if (amount.type is AmountType.Token && callData != null) {
|
||||
createTransactionDataExtras(
|
||||
callData = callData,
|
||||
network = network,
|
||||
nonce = null,
|
||||
gasLimit = null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return@withContext createTransaction(
|
||||
amount = amount,
|
||||
fee = fee,
|
||||
memo = null,
|
||||
destination = destination,
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
txExtras = txExtras,
|
||||
hash = hash,
|
||||
txExtras = getMemoExtras(networkId = network.id.value, memo = memo) ?: extras,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -73,25 +107,16 @@ internal class DefaultTransactionRepository(
|
|||
spenderAddress: String,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
hash: String?,
|
||||
): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = blockchain,
|
||||
derivationPath = network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
val approver = walletManager as? Approver ?: error("Cannot cast to Approver")
|
||||
|
||||
val approvalData = approver.getApproveData(
|
||||
spenderAddress = spenderAddress,
|
||||
value = approvalAmount,
|
||||
)
|
||||
|
||||
val extras = createTransactionDataExtras(
|
||||
data = approvalData,
|
||||
callData = SmartContractCallDataProviderFactory.getApprovalCallData(
|
||||
spenderAddress = spenderAddress,
|
||||
amount = approvalAmount,
|
||||
blockchain = blockchain,
|
||||
),
|
||||
network = network,
|
||||
transactionType = TransactionType.APPROVE,
|
||||
nonce = null,
|
||||
gasLimit = null,
|
||||
)
|
||||
|
|
@ -104,7 +129,6 @@ internal class DefaultTransactionRepository(
|
|||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
txExtras = extras,
|
||||
hash = hash,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -115,9 +139,6 @@ internal class DefaultTransactionRepository(
|
|||
destination: String,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
isSwap: Boolean,
|
||||
txExtras: TransactionExtras?,
|
||||
hash: String?,
|
||||
): Result<Unit> = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val walletManager = walletManagersStore.getSyncOrNull(
|
||||
|
|
@ -129,14 +150,12 @@ internal class DefaultTransactionRepository(
|
|||
val validator = walletManager as? TransactionValidator
|
||||
|
||||
if (validator != null) {
|
||||
val transactionData = walletManager.createTransactionDataInternal(
|
||||
val transactionData = walletManager.createTransaction(
|
||||
amount = amount,
|
||||
fee = fee ?: Fee.Common(amount = amount),
|
||||
memo = memo,
|
||||
destination = destination,
|
||||
network = network,
|
||||
txExtras = txExtras,
|
||||
hash = hash,
|
||||
).copy(
|
||||
extras = getMemoExtras(networkId = network.id.value, memo = memo),
|
||||
)
|
||||
|
||||
validator.validate(transactionData = transactionData)
|
||||
|
|
@ -178,9 +197,8 @@ internal class DefaultTransactionRepository(
|
|||
}
|
||||
|
||||
override fun createTransactionDataExtras(
|
||||
data: String,
|
||||
callData: SmartContractCallData,
|
||||
network: Network,
|
||||
transactionType: TransactionType,
|
||||
nonce: BigInteger?,
|
||||
gasLimit: BigInteger?,
|
||||
): TransactionExtras {
|
||||
|
|
@ -189,15 +207,14 @@ internal class DefaultTransactionRepository(
|
|||
return when {
|
||||
blockchain.isEvm() -> {
|
||||
EthereumTransactionExtras(
|
||||
data = data.hexToBytes(),
|
||||
callData = callData,
|
||||
gasLimit = gasLimit,
|
||||
nonce = nonce,
|
||||
)
|
||||
}
|
||||
blockchain == Blockchain.Tron -> {
|
||||
TronTransactionExtras(
|
||||
data = data.hexToBytes(),
|
||||
txType = convertToSdkTransactionType(transactionType),
|
||||
callData = callData,
|
||||
)
|
||||
}
|
||||
else -> error("Data extras not supported for $blockchain")
|
||||
|
|
@ -226,33 +243,6 @@ internal class DefaultTransactionRepository(
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType {
|
||||
return when (transactionType) {
|
||||
TransactionType.APPROVE -> SdkTransactionType.APPROVE
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun WalletManager.createTransactionDataInternal(
|
||||
amount: Amount,
|
||||
fee: Fee,
|
||||
memo: String?,
|
||||
destination: String,
|
||||
network: Network,
|
||||
txExtras: TransactionExtras?,
|
||||
hash: String?,
|
||||
): TransactionData.Uncompiled {
|
||||
if (txExtras != null && memo != null) {
|
||||
// throw error for now to avoid programmers errors when use extras
|
||||
error("Both txExtras and memo provided, use only one of them")
|
||||
}
|
||||
val extras = txExtras ?: getMemoExtras(network.id.value, memo)
|
||||
return createTransaction(amount, fee, destination).copy(
|
||||
hash = hash,
|
||||
extras = extras,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
|
||||
val blockchain = Blockchain.fromId(networkId)
|
||||
|
|
@ -280,4 +270,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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
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.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,9 +27,7 @@ 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,
|
||||
) : VisaActivationRepository {
|
||||
|
|
@ -36,59 +36,38 @@ 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.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 +77,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 +98,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 +123,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,21 +143,16 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.visa.TangemVisaAuthApi
|
||||
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
|
||||
|
|
@ -20,74 +20,80 @@ internal class DefaultVisaAuthRepository @Inject constructor(
|
|||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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.models.request.RefreshTokenByCardWalletRequest
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 @@ dependencies {
|
|||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.blockaid.models)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
}
|
||||
|
|
@ -5,27 +5,26 @@ import com.squareup.moshi.Moshi
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
|
||||
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
|
||||
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
|
||||
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
|
||||
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
|
||||
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
|
||||
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
|
||||
import com.tangem.data.walletconnect.request.DefaultWcRequestService
|
||||
import com.tangem.data.walletconnect.request.WcMethodHandler
|
||||
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
|
||||
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.datasource.di.SdkMoshi
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
import com.tangem.domain.blockaid.BlockAidVerifier
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
|
||||
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.request.WcRequestService
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
|
|
@ -58,12 +57,12 @@ internal object WalletConnectDataModule {
|
|||
application: Application,
|
||||
sessionsManager: DefaultWcSessionsManager,
|
||||
networkService: DefaultWcRequestService,
|
||||
wcPairFlow: DefaultWcPairUseCase,
|
||||
pairSdkDelegate: WcPairSdkDelegate,
|
||||
): WcInitializeUseCase = DefaultWcInitializeUseCase(
|
||||
application = application,
|
||||
sessionsManager = sessionsManager,
|
||||
networkService = networkService,
|
||||
wcPairFlow = wcPairFlow,
|
||||
pairSdkDelegate = pairSdkDelegate,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
@ -72,16 +71,24 @@ internal object WalletConnectDataModule {
|
|||
sessionsManager: WcSessionsManager,
|
||||
associateNetworksDelegate: AssociateNetworksDelegate,
|
||||
caipNamespaceDelegate: CaipNamespaceDelegate,
|
||||
sdkDelegate: WcPairSdkDelegate,
|
||||
blockAidVerifier: BlockAidVerifier,
|
||||
): DefaultWcPairUseCase = DefaultWcPairUseCase(
|
||||
sessionsManager = sessionsManager,
|
||||
associateNetworksDelegate = associateNetworksDelegate,
|
||||
caipNamespaceDelegate = caipNamespaceDelegate,
|
||||
sdkDelegate = sdkDelegate,
|
||||
blockAidVerifier = blockAidVerifier,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun defaultWcSessionsManager(
|
||||
|
|
@ -107,38 +114,47 @@ internal object WalletConnectDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun defaultWcRequestService(
|
||||
sessionsManager: WcSessionsManager,
|
||||
respondService: WcRespondService,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
diHelperBox: DiHelperBox,
|
||||
): DefaultWcRequestService {
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
|
||||
return DefaultWcRequestService(
|
||||
sessionsManager = sessionsManager,
|
||||
respondService = respondService,
|
||||
requestAdapters = diHelperBox.handlers,
|
||||
requestConverters = diHelperBox.handlers,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcRespondService(): WcRespondService = DefaultWcRespondService()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
|
||||
fun wcEthNetwork(
|
||||
@SdkMoshi moshi: Moshi,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
sessionsManager: WcSessionsManager,
|
||||
factories: WcEthNetwork.Factories,
|
||||
): WcEthNetwork = WcEthNetwork(
|
||||
moshi = moshi,
|
||||
respondService = respondService,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
sessionsManager = sessionsManager,
|
||||
factories = factories,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork()
|
||||
fun wcSolanaNetwork(
|
||||
@SdkMoshi moshi: Moshi,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
sessionsManager: WcSessionsManager,
|
||||
factories: WcSolanaNetwork.Factories,
|
||||
): WcSolanaNetwork = WcSolanaNetwork(
|
||||
moshi = moshi,
|
||||
sessionsManager = sessionsManager,
|
||||
factories = factories,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -156,12 +172,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 +183,16 @@ 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
|
||||
},
|
||||
)
|
||||
|
||||
internal class DiHelperBox(
|
||||
val converters: Map<NamespaceKey, WcNamespaceConverter>,
|
||||
val handlers: Set<WcMethodHandler<WcMethod>>,
|
||||
val converters: Set<WcNamespaceConverter>,
|
||||
val handlers: Set<WcRequestToUseCaseConverter>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
|
||||
import com.tangem.blockchain.common.HEX_PREFIX
|
||||
import com.tangem.blockchain.extensions.isAscii
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.common.extensions.toDecompressedPublicKey
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.SignCollector
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMessageSignUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
internal class DefaultWcEthMessageSignUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.MessageSign,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val signUseCase: SignUseCase,
|
||||
) : BaseWcSignUseCase<Nothing, WcEthMessageSignUseCase.SignModel>(),
|
||||
WcEthMessageSignUseCase {
|
||||
|
||||
override suspend fun SignCollector<WcEthMessageSignUseCase.SignModel>.onSign(
|
||||
state: WcSignState<WcEthMessageSignUseCase.SignModel>,
|
||||
) {
|
||||
val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg)
|
||||
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 = EthereumUtils.prepareSignedMessageData(
|
||||
signedHash = signedHash,
|
||||
hashToSign = hashToSign,
|
||||
publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(),
|
||||
)
|
||||
|
||||
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
|
||||
emit(state.toResult(wcRespondResult))
|
||||
}
|
||||
|
||||
override fun invoke(): Flow<WcSignState<WcEthMessageSignUseCase.SignModel>> = flow {
|
||||
val model = WcEthMessageSignUseCase.SignModel(
|
||||
rawMsg = method.message,
|
||||
account = method.account,
|
||||
humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(),
|
||||
)
|
||||
emitAll(delegate(model))
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): DefaultWcEthMessageSignUseCase
|
||||
}
|
||||
}
|
||||
|
||||
object LegacySdkHelper {
|
||||
private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
|
||||
|
||||
fun createMessageData(message: String): ByteArray {
|
||||
val messageData = try {
|
||||
message.removePrefix(HEX_PREFIX).hexToBytes()
|
||||
} catch (exception: Exception) {
|
||||
message.asciiToHex()?.hexToBytes() ?: byteArrayOf()
|
||||
}
|
||||
|
||||
val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray()
|
||||
return (prefixData + messageData).toKeccak()
|
||||
}
|
||||
|
||||
fun hexToAscii(hex: String): String? {
|
||||
return try {
|
||||
hex.removePrefix(HEX_PREFIX).hexToBytes().map {
|
||||
val char = it.toInt().toChar()
|
||||
if (char.isAscii()) char else return null
|
||||
}.joinToString("")
|
||||
} catch (exception: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.asciiToHex(): String? {
|
||||
return map {
|
||||
if (!it.isAscii()) return null
|
||||
Integer.toHexString(it.code)
|
||||
}.joinToString("")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
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.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.ethereum.WcEthSendTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthTransaction
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
internal class DefaultWcEthSendTransactionUseCase @AssistedInject constructor(
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.SendTransaction,
|
||||
override val respondService: WcRespondService,
|
||||
private val sendTransaction: SendTransactionUseCase,
|
||||
) : BaseWcSignUseCase<Fee, WcEthTransaction>(),
|
||||
WcEthSendTransactionUseCase {
|
||||
|
||||
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,
|
||||
): DefaultWcEthSendTransactionUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
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.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.ethereum.WcEthSignTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthTransaction
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
internal class DefaultWcEthSignTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
private val prepareForSend: PrepareForSendUseCase,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.SignTransaction,
|
||||
) : BaseWcSignUseCase<Fee, WcEthTransaction>(),
|
||||
WcEthSignTransactionUseCase {
|
||||
|
||||
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,
|
||||
): DefaultWcEthSignTransactionUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -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.ethereum.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,57 +2,76 @@ package com.tangem.data.walletconnect.network.ethereum
|
|||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.walletconnect.model.CAIP2
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.data.walletconnect.request.WcMethodHandler
|
||||
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
|
||||
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import jakarta.inject.Inject
|
||||
|
||||
internal class WcEthNetwork(
|
||||
internal class WcEthNetwork constructor(
|
||||
private val moshi: Moshi,
|
||||
private val respondService: WcRespondService,
|
||||
) : WcMethodHandler<WcEthMethod>, WcUseCasesFlowProvider, WcNamespaceConverter {
|
||||
|
||||
private val _useCases: Channel<WcUseCase> = Channel(Channel.BUFFERED)
|
||||
override val useCases = _useCases.receiveAsFlow()
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val sessionsManager: WcSessionsManager,
|
||||
private val factories: Factories,
|
||||
) : WcRequestToUseCaseConverter, WcNamespaceConverter {
|
||||
|
||||
override val namespaceKey: NamespaceKey = NamespaceKey("eip155")
|
||||
|
||||
override fun canHandle(methodName: String): Boolean {
|
||||
return Name.entries.find { it.raw == methodName } != null
|
||||
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
|
||||
val methodKey = request.request.method
|
||||
val name = Name.entries.find { it.raw == methodKey } ?: return null
|
||||
val method: WcEthMethod = name.toMethod(request) ?: return null
|
||||
val session = sessionsManager.findSessionByTopic(request.topic) ?: return null
|
||||
val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null
|
||||
val context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network)
|
||||
|
||||
return when (method) {
|
||||
is WcEthMethod.MessageSign -> factories.messageSign.create(context, method)
|
||||
is WcEthMethod.SendTransaction -> factories.sendTransaction.create(context, method)
|
||||
is WcEthMethod.SignTransaction -> factories.signTransaction.create(context, method)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(methodName: String, params: String): WcEthMethod? {
|
||||
val name = Name.entries.find { it.raw == methodName } ?: return null
|
||||
return when (name) {
|
||||
Name.Sign -> TODO()
|
||||
Name.PersonalSign -> WcMethodHandler.fromJson<SignMessage>(params, moshi)
|
||||
private fun Name.toMethod(request: WcSdkSessionRequest): WcEthMethod? {
|
||||
val rawParams = request.request.params
|
||||
return when (this) {
|
||||
Name.EthSign,
|
||||
Name.PersonalSign,
|
||||
-> moshi.fromJson<List<String>>(rawParams)?.let { list ->
|
||||
val accountIndex = if (this == Name.EthSign) 0 else 1
|
||||
val messageIndex = if (this == Name.EthSign) 1 else 0
|
||||
val account = list.getOrNull(accountIndex) ?: return@let null
|
||||
val message = list.getOrNull(messageIndex) ?: return@let null
|
||||
WcEthMethod.MessageSign(account = account, message = message)
|
||||
}
|
||||
Name.SignTypeData -> TODO()
|
||||
Name.SignTypeDataV4 -> TODO()
|
||||
Name.SignTransaction -> TODO()
|
||||
Name.SendTransaction -> TODO()
|
||||
Name.SignTransaction,
|
||||
Name.SendTransaction,
|
||||
-> moshi.fromJson<List<WcEthTransactionParams>>(rawParams)
|
||||
?.firstOrNull()
|
||||
?.let {
|
||||
if (this == Name.SignTransaction) {
|
||||
WcEthMethod.SignTransaction(transaction = it)
|
||||
} else {
|
||||
WcEthMethod.SendTransaction(transaction = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handle(wcRequest: WcRequest<WcMethod>) {
|
||||
wcRequest as WcRequest<WcEthMethod>
|
||||
val useCase = when (wcRequest.method) {
|
||||
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
|
||||
}
|
||||
_useCases.trySend(useCase)
|
||||
}
|
||||
|
||||
enum class Name(val raw: String) {
|
||||
Sign("eth_sign"),
|
||||
EthSign("eth_sign"),
|
||||
PersonalSign("personal_sign"),
|
||||
SignTypeData("eth_signTypedData"),
|
||||
SignTypeDataV4("eth_signTypedData_v4"),
|
||||
|
|
@ -60,13 +79,18 @@ internal class WcEthNetwork(
|
|||
SendTransaction("eth_sendTransaction"),
|
||||
}
|
||||
|
||||
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
|
||||
return toNetwork(chainId, wallet, excludedBlockchains)
|
||||
}
|
||||
|
||||
override fun toBlockchain(chainId: CAIP2): Blockchain? {
|
||||
if (chainId.namespace != namespaceKey.key) return null
|
||||
val ethChainId = chainId.reference.toIntOrNull() ?: return null
|
||||
return Blockchain.fromChainId(ethChainId)
|
||||
}
|
||||
|
||||
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
|
||||
override fun toCAIP2(network: Network): CAIP2? {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
if (!blockchain.isEvm()) return null
|
||||
val chainId = blockchain.getChainId() ?: return null
|
||||
return CAIP2(
|
||||
|
|
@ -74,4 +98,10 @@ internal class WcEthNetwork(
|
|||
reference = chainId.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
internal class Factories @Inject constructor(
|
||||
val messageSign: DefaultWcEthMessageSignUseCase.Factory,
|
||||
val sendTransaction: DefaultWcEthSendTransactionUseCase.Factory,
|
||||
val signTransaction: DefaultWcEthSignTransactionUseCase.Factory,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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.domain.transaction.usecase.PrepareForSendUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.usecase.sign.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 private val method: WcSolanaMethod.SignAllTransaction,
|
||||
) : BaseWcSignUseCase<Nothing, List<TransactionData.Compiled>>(),
|
||||
WcSolanaSignAllTransactionUseCase {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
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.domain.transaction.usecase.PrepareForSendUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.usecase.sign.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 private val method: WcSolanaMethod.SignTransaction,
|
||||
) : BaseWcSignUseCase<Nothing, TransactionData.Compiled>(),
|
||||
WcSolanaSignTransactionUseCase {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -1,13 +1,45 @@
|
|||
package com.tangem.data.walletconnect.network.solana
|
||||
|
||||
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.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.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import jakarta.inject.Inject
|
||||
|
||||
internal class WcSolanaNetwork constructor(
|
||||
private val moshi: Moshi,
|
||||
private val sessionsManager: WcSessionsManager,
|
||||
private val factories: Factories,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
) : WcNamespaceConverter, WcRequestToUseCaseConverter {
|
||||
|
||||
internal class WcSolanaNetwork : WcNamespaceConverter {
|
||||
override val namespaceKey: NamespaceKey = NamespaceKey("solana")
|
||||
|
||||
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
|
||||
val methodKey = request.request.method
|
||||
val name = Name.entries.find { it.raw == methodKey } ?: 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 context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network)
|
||||
return when (method) {
|
||||
is WcSolanaMethod.SignMessage -> TODO()
|
||||
is WcSolanaMethod.SignTransaction -> factories.signTransaction.create(context, method)
|
||||
is WcSolanaMethod.SignAllTransaction -> factories.signAllTransaction.create(context, method)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toBlockchain(chainId: CAIP2): Blockchain? {
|
||||
if (chainId.namespace != namespaceKey.key) return null
|
||||
return when (chainId.reference) {
|
||||
|
|
@ -17,7 +49,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 +67,32 @@ internal class WcSolanaNetwork : WcNamespaceConverter {
|
|||
)
|
||||
}
|
||||
|
||||
private fun Name.toMethod(request: WcSdkSessionRequest): WcSolanaMethod? {
|
||||
val rawParams = request.request.params
|
||||
return when (this) {
|
||||
Name.SignMessage -> moshi.fromJson<WcSolanaSignMessageRequest>(rawParams)?.let { request ->
|
||||
WcSolanaMethod.SignMessage(pubKey = request.publicKey, message = request.message)
|
||||
}
|
||||
Name.SignTransaction -> moshi.fromJson<WcSolanaSignTransactionRequest>(rawParams)?.let { request ->
|
||||
WcSolanaMethod.SignTransaction(request.transaction)
|
||||
}
|
||||
Name.SendAllTransaction -> moshi.fromJson<List<String>>(rawParams)?.let { list ->
|
||||
WcSolanaMethod.SignAllTransaction(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Name(val raw: String) {
|
||||
SignMessage("solana_signMessage"),
|
||||
SignTransaction("solana_signTransaction"),
|
||||
SendAllTransaction("solana_signAllTransactions"),
|
||||
}
|
||||
|
||||
internal class Factories @Inject constructor(
|
||||
val signTransaction: DefaultWcSolanaSignTransactionUseCase.Factory,
|
||||
val signAllTransaction: DefaultWcSolanaSignAllTransactionUseCase.Factory,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAINNET_CHAIN_ID = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
|
||||
private const val TESTNET_CHAIN_ID = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z"
|
||||
|
|
|
|||
|
|
@ -1,92 +1,72 @@
|
|||
package com.tangem.data.walletconnect.pair
|
||||
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.currency.getNetwork
|
||||
import com.tangem.data.walletconnect.model.CAIP2
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.walletconnect.model.WcNetwork
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
|
||||
internal class AssociateNetworksDelegate constructor(
|
||||
private val namespaceConverters: Map<NamespaceKey, WcNamespaceConverter>,
|
||||
private val namespaceConverters: Set<WcNamespaceConverter>,
|
||||
private val getWallets: GetWalletsUseCase,
|
||||
private val currenciesRepository: CurrenciesRepository,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
@Throws(WcPairError.UnsupportedNetworks::class)
|
||||
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWalletId, ProposalNetwork> {
|
||||
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWallet, ProposalNetwork> {
|
||||
val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency }
|
||||
val requiredNamespaces: Set<CAIP2> = sessionProposal.requiredNamespaces.setOfChainId()
|
||||
val optionalNamespaces: Set<CAIP2> = sessionProposal.optionalNamespaces.setOfChainId()
|
||||
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
|
||||
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
|
||||
|
||||
return userWallets.associate { wallet ->
|
||||
wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces)
|
||||
}
|
||||
return userWallets
|
||||
.associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) }
|
||||
}
|
||||
|
||||
private suspend fun mapNetworksForWallet(
|
||||
wallet: UserWallet,
|
||||
requiredNamespaces: Set<CAIP2>,
|
||||
optionalNamespaces: Set<CAIP2>,
|
||||
requiredNamespaces: Set<String>,
|
||||
optionalNamespaces: Set<String>,
|
||||
): ProposalNetwork {
|
||||
val walletNetworks = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(wallet.walletId)
|
||||
.filterIsInstance<CryptoCurrency.Coin>()
|
||||
.map { it.network }
|
||||
|
||||
val unknownRequired = mutableSetOf<WcNetwork.Unknown>()
|
||||
val missingRequired = mutableSetOf<WcNetwork.Supported>()
|
||||
val required = mutableSetOf<WcNetwork.Supported>()
|
||||
val available = mutableSetOf<WcNetwork.Supported>()
|
||||
val notAdded = mutableSetOf<WcNetwork.Supported>()
|
||||
|
||||
fun CAIP2.toBlockchain() = namespaceConverters[NamespaceKey(this.namespace)]?.toBlockchain(this)
|
||||
fun Blockchain.toNetwork() = getNetwork(
|
||||
blockchain = this,
|
||||
extraDerivationPath = null,
|
||||
scanResponse = wallet.scanResponse,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
)
|
||||
val unknownRequired = mutableSetOf<String>()
|
||||
val missingRequired = mutableSetOf<Network>()
|
||||
val required = mutableSetOf<Network>()
|
||||
val available = mutableSetOf<Network>()
|
||||
val notAdded = mutableSetOf<Network>()
|
||||
|
||||
requiredNamespaces.forEach { chainId ->
|
||||
val blockchain = chainId.toBlockchain()
|
||||
if (blockchain == null) {
|
||||
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId)))
|
||||
return@forEach
|
||||
}
|
||||
val wcNetwork = blockchain.toNetwork()
|
||||
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
|
||||
if (wcNetwork == null) {
|
||||
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain)))
|
||||
unknownRequired.add(missingNetworkName(chainId))
|
||||
return@forEach
|
||||
}
|
||||
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
|
||||
if (walletNetwork == null) {
|
||||
missingRequired.add(WcNetwork.Supported(wcNetwork))
|
||||
missingRequired.add(wcNetwork)
|
||||
} else {
|
||||
required.add(WcNetwork.Supported(walletNetwork))
|
||||
required.add(walletNetwork)
|
||||
}
|
||||
}
|
||||
optionalNamespaces.forEach { chainId ->
|
||||
val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach
|
||||
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
|
||||
?: return@forEach
|
||||
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
|
||||
if (walletNetwork == null) {
|
||||
available.add(WcNetwork.Supported(wcNetwork))
|
||||
available.add(wcNetwork)
|
||||
} else {
|
||||
notAdded.add(WcNetwork.Supported(walletNetwork))
|
||||
notAdded.add(walletNetwork)
|
||||
}
|
||||
}
|
||||
if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired)
|
||||
return ProposalNetwork(
|
||||
walletId = wallet.walletId,
|
||||
wallet = wallet,
|
||||
missingRequired = missingRequired,
|
||||
required = required,
|
||||
available = available,
|
||||
|
|
@ -94,10 +74,8 @@ internal class AssociateNetworksDelegate constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<CAIP2> =
|
||||
this.values.flatMap { proposal -> proposal.chains ?: listOf() }
|
||||
.mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet()
|
||||
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<String> =
|
||||
this.values.flatMap { proposal -> proposal.chains ?: listOf() }.toSet()
|
||||
|
||||
private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase)
|
||||
private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName()
|
||||
private fun missingNetworkName(chainId: String): String = chainId.replaceFirstChar(Char::titlecase)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
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.blockaid.BlockAidVerifier
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.WcSessionApprove
|
||||
|
|
@ -15,15 +17,13 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
|
|||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox")
|
||||
|
||||
|
|
@ -31,27 +31,19 @@ internal class DefaultWcPairUseCase(
|
|||
private val sessionsManager: WcSessionsManager,
|
||||
private val associateNetworksDelegate: AssociateNetworksDelegate,
|
||||
private val caipNamespaceDelegate: CaipNamespaceDelegate,
|
||||
) : WcPairUseCase, WcSdkObserver {
|
||||
private val sdkDelegate: WcPairSdkDelegate,
|
||||
private val blockAidVerifier: BlockAidVerifier,
|
||||
) : WcPairUseCase {
|
||||
|
||||
private val onCallTerminalAction = Channel<TerminalAction>()
|
||||
private val onSessionProposal =
|
||||
Channel<Pair<Wallet.Model.SessionProposal, Wallet.Model.VerifyContext>>(Channel.BUFFERED)
|
||||
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>(Channel.BUFFERED)
|
||||
|
||||
override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow<WcPairState> {
|
||||
return flow {
|
||||
emit(WcPairState.Loading)
|
||||
|
||||
// call sdk.pair and wait result, finish flow on error
|
||||
walletKitPair(uri).onLeft { throwable ->
|
||||
emit(WcPairState.Error(WcPairError.Unknown(throwable.localizedMessage.orEmpty())))
|
||||
return@flow
|
||||
}
|
||||
// wait for sdk onSessionProposal callback
|
||||
val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow()
|
||||
.first { (sessionProposal, verifyContext) ->
|
||||
true // todo(wc) check verifyContext? compare uri?
|
||||
}
|
||||
val sdkSessionProposal = sdkDelegate.pair(uri)
|
||||
.onLeft { emit(WcPairState.Error(it)) }
|
||||
.getOrNull() ?: return@flow
|
||||
|
||||
// check unsupported dApps, just local constant for now, finish if unsupported
|
||||
if (sdkSessionProposal.name in unsupportedDApps) {
|
||||
|
|
@ -62,44 +54,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 +95,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 +129,7 @@ internal class DefaultWcPairUseCase(
|
|||
val dAppSession = WcSessionProposal(
|
||||
dAppMetaData = appMetaData,
|
||||
proposalNetwork = proposalNetwork,
|
||||
securityStatus = Any(),
|
||||
securityStatus = verificationInfo,
|
||||
)
|
||||
WcPairState.Proposal(dAppSession)
|
||||
}.fold(onSuccess = { it.right() }, onFailure = {
|
||||
|
|
@ -209,10 +139,13 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.tangem.data.walletconnect.pair
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal class WcPairSdkDelegate : WcSdkObserver {
|
||||
|
||||
private val onSessionProposal = Channel<Wallet.Model.SessionProposal>()
|
||||
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>()
|
||||
|
||||
suspend fun pair(url: String): Either<WcPairError, Wallet.Model.SessionProposal> = coroutineScope {
|
||||
suspend fun proposalCallback() = onSessionProposal
|
||||
.receiveAsFlow()
|
||||
.filter { proposal -> proposal.url == url }
|
||||
.first()
|
||||
|
||||
val pairCall = async { sdkPair(url) }
|
||||
val proposal = async { proposalCallback() }
|
||||
pairCall.await().map { proposal.await() }
|
||||
}
|
||||
|
||||
suspend fun approve(
|
||||
sessionApprove: Wallet.Params.SessionApprove,
|
||||
): Either<WcPairError, Wallet.Model.SettledSessionResponse.Result> = coroutineScope {
|
||||
suspend fun approveCallback() = onSessionSettleResponse
|
||||
.receiveAsFlow()
|
||||
.first()
|
||||
|
||||
val approveCall = async { sdkApprove(sessionApprove) }
|
||||
val approveCallback = async { approveCallback() }
|
||||
approveCall.await()
|
||||
.onLeft { return@coroutineScope it.left() }
|
||||
when (val result = approveCallback.await()) {
|
||||
is Wallet.Model.SettledSessionResponse.Result -> result.right()
|
||||
is Wallet.Model.SettledSessionResponse.Error ->
|
||||
WcPairError.ExternalApprovalError(result.errorMessage).left()
|
||||
}
|
||||
}
|
||||
|
||||
fun rejectSession(proposerPublicKey: String) {
|
||||
WalletKit.rejectSession(
|
||||
params = Wallet.Params.SessionReject(
|
||||
proposerPublicKey = proposerPublicKey,
|
||||
reason = "",
|
||||
),
|
||||
onSuccess = {},
|
||||
onError = {},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSessionProposal(
|
||||
sessionProposal: Wallet.Model.SessionProposal,
|
||||
verifyContext: Wallet.Model.VerifyContext,
|
||||
) {
|
||||
// Triggered when wallet receives the session proposal sent by a Dapp
|
||||
onSessionProposal.trySend(sessionProposal)
|
||||
}
|
||||
|
||||
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
|
||||
// Triggered when wallet receives the session settlement response from Dapp
|
||||
onSessionSettleResponse.trySend(settleSessionResponse)
|
||||
}
|
||||
|
||||
private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either<WcPairError, Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
WalletKit.approveSession(
|
||||
params = sessionApprove,
|
||||
onSuccess = { continuation.resume(Unit.right()) },
|
||||
onError = { continuation.resume(it.throwable.toPairError()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sdkPair(uri: String): Either<WcPairError, Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
WalletKit.pair(
|
||||
params = Wallet.Params.Pair(uri),
|
||||
onSuccess = { continuation.resume(Unit.right()) },
|
||||
onError = { continuation.resume(it.throwable.toPairError()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left()
|
||||
}
|
||||
|
|
@ -3,23 +3,20 @@ package com.tangem.data.walletconnect.request
|
|||
import com.reown.walletkit.client.Wallet
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.request.WcRequestService
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class DefaultWcRequestService(
|
||||
private val sessionsManager: WcSessionsManager,
|
||||
private val respondService: WcRespondService,
|
||||
private val requestAdapters: Set<WcMethodHandler<WcMethod>>,
|
||||
private val requestConverters: Set<WcRequestToUseCaseConverter>,
|
||||
private val scope: CoroutineScope,
|
||||
) : WcRequestService, WcSdkObserver {
|
||||
) : WcSdkObserver, WcUseCasesFlowProvider {
|
||||
|
||||
override val requests: MutableSharedFlow<WcRequest<*>> = MutableSharedFlow()
|
||||
private val _useCases: Channel<WcMethodUseCase> = Channel(Channel.BUFFERED)
|
||||
override val useCases = _useCases.receiveAsFlow()
|
||||
|
||||
override fun onSessionRequest(
|
||||
sessionRequest: Wallet.Model.SessionRequest,
|
||||
|
|
@ -27,21 +24,10 @@ internal class DefaultWcRequestService(
|
|||
) {
|
||||
// Triggered when a Dapp sends SessionRequest to sign a transaction or a message
|
||||
val sr = WcSdkSessionRequestConverter.convert(sessionRequest)
|
||||
val method = sr.request.method
|
||||
val params = sr.request.params
|
||||
scope.launch {
|
||||
val session = sessionsManager.findSessionByTopic(sr.topic)
|
||||
val handler: WcMethodHandler<WcMethod>? = requestAdapters.firstOrNull { it.canHandle(method) }
|
||||
|
||||
val deserialized: WcMethod? = handler?.deserialize(method, params)
|
||||
if (handler == null || deserialized == null || session == null) {
|
||||
respondService.rejectRequest(sr, "UnsupportedMethod") // todo(wc) use our domain error
|
||||
return@launch
|
||||
}
|
||||
|
||||
val wcRequest = WcRequest(sr, session, deserialized)
|
||||
handler.handle(wcRequest)
|
||||
requests.emit(wcRequest)
|
||||
val useCase = requestConverters.firstNotNullOfOrNull { it.toUseCase(sr) }
|
||||
?: return@launch // todo(wc) handle unsupported
|
||||
_useCases.trySend(useCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.data.walletconnect.request
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
|
||||
interface WcMethodHandler<out M : WcMethod> {
|
||||
fun canHandle(methodName: String): Boolean
|
||||
fun deserialize(methodName: String, params: String): M?
|
||||
fun handle(wcRequest: WcRequest<WcMethod>)
|
||||
|
||||
companion object {
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified T> fromJson(params: String, moshi: Moshi): T? =
|
||||
runCatching { moshi.adapter<T>().fromJsonValue(params) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.data.walletconnect.request
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
|
||||
interface WcRequestToUseCaseConverter {
|
||||
suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase?
|
||||
|
||||
companion object {
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified T> Moshi.fromJson(params: String): T? =
|
||||
runCatching { this.adapter<T>().fromJson(params) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import arrow.core.right
|
|||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
|
|
@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) {
|
||||
WalletKit.respondSessionRequest(
|
||||
params = Wallet.Params.SessionRequestResponse(
|
||||
sessionTopic = request.topic,
|
||||
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
|
||||
id = request.request.id,
|
||||
code = 0,
|
||||
message = message,
|
||||
),
|
||||
),
|
||||
onSuccess = {},
|
||||
onError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.data.walletconnect.respond
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
|
||||
interface WcRespondService {
|
||||
suspend fun respond(request: WcSdkSessionRequest, response: String): Either<Throwable, Unit>
|
||||
suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either<Throwable, Unit>
|
||||
fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "")
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.data.walletconnect.sessions
|
|||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.domain.blockaid.models.dapp.CheckDAppResult
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
|
|
@ -12,7 +13,7 @@ import com.tangem.domain.walletconnect.model.WcSession
|
|||
import com.tangem.domain.walletconnect.model.WcSessionDTO
|
||||
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -25,7 +26,7 @@ import timber.log.Timber
|
|||
import kotlin.coroutines.resume
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal class DefaultWcSessionsManager constructor(
|
||||
internal class DefaultWcSessionsManager(
|
||||
private val store: WalletConnectStore,
|
||||
private val legacyStore: WalletConnectSessionsRepository,
|
||||
private val getWallets: GetWalletsUseCase,
|
||||
|
|
@ -36,20 +37,22 @@ internal class DefaultWcSessionsManager constructor(
|
|||
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
|
||||
private val oneTimeMigration = MutableStateFlow(true)
|
||||
|
||||
override val sessions: Flow<Map<UserWalletId, List<WcSession>>>
|
||||
get() = store.sessions
|
||||
.transform { inStore ->
|
||||
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
|
||||
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
|
||||
.transform { pair ->
|
||||
val (wallets, inStore) = pair
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
if (oneTimeMigration.value) {
|
||||
oneTimeMigration.value = false
|
||||
val someMigrated = migrateLegacyStore(inStore)
|
||||
val someMigrated = migrateLegacyStore(inStore, inSdk, wallets)
|
||||
if (someMigrated) return@transform // ignore emit, wait next one
|
||||
}
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
val associatedSessions: List<WcSession> = associateWithSdk(inSdk, inStore)
|
||||
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
|
||||
val someRemove = removeUnknownSessions(inStore, associatedSessions)
|
||||
if (someRemove) return@transform // ignore emit, wait next one
|
||||
emit(associatedSessions.groupBy { it.userWalletId })
|
||||
emit(associatedSessions.groupBy { it.wallet })
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.io)
|
||||
|
||||
override fun onWcSdkInit() {
|
||||
|
|
@ -57,11 +60,11 @@ internal class DefaultWcSessionsManager constructor(
|
|||
listenOnSessionDelete()
|
||||
}
|
||||
|
||||
override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId))
|
||||
override suspend fun saveSession(session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId, session.securityStatus))
|
||||
}
|
||||
|
||||
override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either<Throwable, Unit> {
|
||||
override suspend fun removeSession(session: WcSession): Either<Throwable, Unit> {
|
||||
val topic = session.sdkModel.topic
|
||||
val sdkCall = sdkDisconnectSession(topic)
|
||||
sdkCall.onLeft { return it.left() }
|
||||
|
|
@ -80,9 +83,17 @@ internal class DefaultWcSessionsManager constructor(
|
|||
}
|
||||
|
||||
override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) {
|
||||
val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null
|
||||
val storedSession = sessions.firstOrNull()
|
||||
?.values?.flatten()
|
||||
?.firstOrNull { it.sdkModel.topic == topic }
|
||||
?: return@withContext null
|
||||
val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wallet = storedSession.wallet
|
||||
WcSession(
|
||||
wallet = wallet,
|
||||
sdkModel = WcSdkSessionConverter.convert(sdkSession),
|
||||
securityStatus = storedSession.securityStatus,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
|
||||
|
|
@ -90,28 +101,41 @@ internal class DefaultWcSessionsManager constructor(
|
|||
onSessionDelete.trySend(sessionDelete)
|
||||
}
|
||||
|
||||
private suspend fun migrateLegacyStore(inNewStoreSessions: Set<WcSessionDTO>): Boolean {
|
||||
val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId }
|
||||
val inLegacyStoreSessions = walletIds
|
||||
private suspend fun migrateLegacyStore(
|
||||
inNewStore: Set<WcSessionDTO>,
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
wallets: List<UserWallet>,
|
||||
): Boolean {
|
||||
val walletIds = wallets.map { wallet -> wallet.walletId }
|
||||
val inLegacyStore = walletIds
|
||||
.map { walletId ->
|
||||
flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) }
|
||||
flow {
|
||||
emit(
|
||||
legacyStore.loadSessions(walletId.stringValue).map {
|
||||
WcSessionDTO(it.topic, walletId, CheckDAppResult.FAILED_TO_VERIFY)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.merge()
|
||||
.reduce { accumulator, value -> accumulator.plus(value) }
|
||||
// migrate only active legacySessions
|
||||
.filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } }
|
||||
|
||||
val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions)
|
||||
val mustSaveInNewStore = inLegacyStore.subtract(inNewStore)
|
||||
if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore)
|
||||
return mustSaveInNewStore.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun associateWithSdk(
|
||||
sdkSessions: List<Wallet.Model.Session>,
|
||||
storeSessions: Set<WcSessionDTO>,
|
||||
private fun associate(
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
inStore: Set<WcSessionDTO>,
|
||||
wallets: List<UserWallet>,
|
||||
): List<WcSession> {
|
||||
val wcSessions = sdkSessions.mapNotNull { sdkSession ->
|
||||
val storedSessions = storeSessions.find { it.topic == sdkSession.topic }
|
||||
?: return@mapNotNull null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wcSessions = inStore.mapNotNull { session ->
|
||||
val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null
|
||||
val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null
|
||||
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession), session.securityStatus)
|
||||
}
|
||||
return wcSessions
|
||||
}
|
||||
|
|
@ -122,9 +146,6 @@ internal class DefaultWcSessionsManager constructor(
|
|||
val haveSomeUnknown = unknownStoredSessions.isNotEmpty()
|
||||
|
||||
if (haveSomeUnknown) {
|
||||
unknownStoredSessions.forEach { unknown ->
|
||||
legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic)
|
||||
}
|
||||
store.removeSessions(unknownStoredSessions.toSet())
|
||||
}
|
||||
return haveSomeUnknown
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue