Updated on 2026-08-14
This commit is contained in:
commit
dbb63a396f
448 changed files with 12230 additions and 2075 deletions
1
data/blockaid/.gitignore
vendored
Normal file
1
data/blockaid/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
41
data/blockaid/build.gradle.kts
Normal file
41
data/blockaid/build.gradle.kts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
plugins {
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.android.library)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.blockaid"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/* Project - Domain */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.blockaid.models)
|
||||
|
||||
/* Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/* DI */
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/* Tangem libraries */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.tangem.data.blockaid
|
||||
|
||||
import com.domain.blockaid.models.dapp.CheckDAppResult
|
||||
import com.domain.blockaid.models.transaction.*
|
||||
import com.domain.blockaid.models.transaction.simultation.ApprovedAmount
|
||||
import com.domain.blockaid.models.transaction.simultation.SimulationData
|
||||
import com.domain.blockaid.models.transaction.simultation.TokenInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.AmountInfo
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.RpcData
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.*
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SUCCESS_STATUS = "Success"
|
||||
private const val DOMAIN_CHECKED_STATUS = "hit"
|
||||
private const val VALIDATION_SAFE_STATUS = "Benign"
|
||||
|
||||
internal class BlockAidMapper @Inject constructor() {
|
||||
|
||||
fun mapToDomain(from: DomainScanResponse): CheckDAppResult {
|
||||
return when {
|
||||
from.status != DOMAIN_CHECKED_STATUS -> CheckDAppResult.FAILED_TO_VERIFY
|
||||
from.isMalicious == true -> CheckDAppResult.UNSAFE
|
||||
else -> CheckDAppResult.SAFE
|
||||
}
|
||||
}
|
||||
|
||||
fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult {
|
||||
return CheckTransactionResult(
|
||||
validation = when {
|
||||
from.validation.status != SUCCESS_STATUS -> ValidationResult.FAILED_TO_VALIDATE
|
||||
from.validation.resultType == VALIDATION_SAFE_STATUS -> ValidationResult.SAFE
|
||||
else -> ValidationResult.UNSAFE
|
||||
},
|
||||
simulation = if (from.simulation.status != SUCCESS_STATUS) {
|
||||
SimulationResult.FailedToSimulate
|
||||
} else {
|
||||
mapSimulationSuccessResult(from.simulation.accountSummary)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun mapToEvmRequest(from: TransactionData): EvmTransactionScanRequest {
|
||||
return EvmTransactionScanRequest(
|
||||
chain = from.chain,
|
||||
accountAddress = from.accountAddress,
|
||||
method = from.method,
|
||||
data = RpcData(
|
||||
method = from.method,
|
||||
params = (from.params as TransactionParams.Evm).params,
|
||||
),
|
||||
metadata = TransactionMetadata(from.domainUrl),
|
||||
)
|
||||
}
|
||||
|
||||
fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest {
|
||||
return SolanaTransactionScanRequest(
|
||||
chain = from.chain,
|
||||
accountAddress = from.accountAddress,
|
||||
metadata = TransactionMetadata(from.domainUrl),
|
||||
method = from.method,
|
||||
transactions = (from.params as TransactionParams.Solana).transactions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSimulationSuccessResult(from: AccountSummaryResponse): SimulationResult {
|
||||
return when {
|
||||
from.assetsDiffs.isEmpty() && from.exposures.isNotEmpty() -> mapApproveTransaction(from.exposures)
|
||||
from.assetsDiffs.isNotEmpty() && from.exposures.isEmpty() -> mapSendReceiveTransaction(from.assetsDiffs)
|
||||
else -> SimulationResult.FailedToSimulate
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApproveTransaction(exposures: List<Exposure>): SimulationResult {
|
||||
val amounts = exposures.flatMap { exposure ->
|
||||
val tokenInfo = TokenInfo(
|
||||
chainId = exposure.asset.chainId,
|
||||
logoUrl = exposure.asset.logoUrl,
|
||||
symbol = exposure.asset.symbol,
|
||||
)
|
||||
exposure.spenders.flatMap { (_, spender) ->
|
||||
val isUnlimited = spender.isApprovedForAll == true
|
||||
spender.exposure.mapNotNull { detail ->
|
||||
ApprovedAmount(
|
||||
approvedAmount = detail.value.toBigDecimalOrNull() ?: return@mapNotNull null,
|
||||
isUnlimited = isUnlimited,
|
||||
tokenInfo = tokenInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (amounts.isNotEmpty()) {
|
||||
SimulationResult.Success(SimulationData.Approve(amounts))
|
||||
} else {
|
||||
SimulationResult.FailedToSimulate
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapSendReceiveTransaction(assetDiffs: List<AssetDiff>): SimulationResult {
|
||||
val sendInfo = arrayListOf<AmountInfo>()
|
||||
val receiveInfo = arrayListOf<AmountInfo>()
|
||||
|
||||
assetDiffs.forEach { diff ->
|
||||
val token = TokenInfo(
|
||||
chainId = diff.asset.chainId,
|
||||
logoUrl = diff.asset.logoUrl,
|
||||
symbol = diff.asset.symbol,
|
||||
)
|
||||
diff.outTransfer.orEmpty().forEach { transfer ->
|
||||
transfer.value.toBigDecimalOrNull()?.let { amount ->
|
||||
sendInfo.add(AmountInfo(amount = amount, token = token))
|
||||
}
|
||||
}
|
||||
diff.inTransfer.orEmpty().forEach { transfer ->
|
||||
transfer.value.toBigDecimalOrNull()?.let { amount ->
|
||||
receiveInfo.add(AmountInfo(amount = amount, token = token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) {
|
||||
SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo))
|
||||
} else {
|
||||
SimulationResult.FailedToSimulate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,40 @@
|
|||
package com.tangem.data.blockaid
|
||||
|
||||
import com.domain.blockaid.models.dapp.CheckDAppResult
|
||||
import com.domain.blockaid.models.dapp.DAppData
|
||||
import com.domain.blockaid.models.transaction.CheckTransactionResult
|
||||
import com.domain.blockaid.models.transaction.TransactionData
|
||||
import com.domain.blockaid.models.transaction.TransactionParams
|
||||
import com.tangem.datasource.api.common.blockaid.BlockAidApi
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultBlockAidRepository @Inject constructor(
|
||||
private val api: BlockAidApi,
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val mapper: BlockAidMapper,
|
||||
) : BlockAidRepository {
|
||||
|
||||
override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult {
|
||||
val response = withContext(dispatcherProvider.io) {
|
||||
api.scanDomain(DomainScanRequest(data.url))
|
||||
}
|
||||
return mapper.mapToDomain(response)
|
||||
}
|
||||
|
||||
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
|
||||
val response = withContext(dispatcherProvider.io) {
|
||||
when (data.params) {
|
||||
is TransactionParams.Evm -> {
|
||||
api.scanJsonRpc(mapper.mapToEvmRequest(data))
|
||||
}
|
||||
is TransactionParams.Solana -> {
|
||||
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapper.mapToDomain(response)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 @@
|
|||
package com.tangem.data.blockaid.di
|
||||
|
||||
import com.tangem.data.blockaid.BlockAidRepository
|
||||
import com.tangem.data.blockaid.DefaultBlockAidRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface BlockAidDataInternalModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindRepository(repository: DefaultBlockAidRepository): BlockAidRepository
|
||||
}
|
||||
|
|
@ -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.tangem.datasource.api.common.blockaid.models.response.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class BlockAidMapperTest {
|
||||
|
||||
private val mapper = BlockAidMapper()
|
||||
|
||||
@Test
|
||||
fun whenStatusHitAndIsMaliciousFalseThenMapToDomainReturnsSafe() {
|
||||
val response = DomainScanResponse(status = "hit", isMalicious = false)
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(CheckDAppResult.SAFE, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenStatusHitAndIsMaliciousTrueThenMapToDomainReturnsUnsafe() {
|
||||
val response = DomainScanResponse(status = "hit", isMalicious = true)
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(CheckDAppResult.UNSAFE, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenStatusNotHitThenMapToDomainReturnsFailedToVerify() {
|
||||
val response = DomainScanResponse(status = "miss", isMalicious = false)
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(CheckDAppResult.FAILED_TO_VERIFY, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseBenignValidationThenReturnsSafeValidation() {
|
||||
val spenderDetails = SpenderDetails(
|
||||
isApprovedForAll = true,
|
||||
exposure = listOf(ExposureDetail(value = "1000.0", rawValue = "0x123")),
|
||||
)
|
||||
val exposure = Exposure(
|
||||
asset = Asset(chainId = 1, logoUrl = "logo", symbol = "PEPE"),
|
||||
spenders = mapOf("spender" to spenderDetails),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(assetsDiffs = emptyList(), exposures = listOf(exposure)),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(ValidationResult.SAFE, result.validation)
|
||||
|
||||
val simulation = result.simulation as? SimulationResult.Success
|
||||
assertNotNull(simulation)
|
||||
|
||||
val approve = simulation?.data as? SimulationData.Approve
|
||||
assertNotNull(approve)
|
||||
assertEquals(1, approve?.approvedAmounts?.size)
|
||||
assertEquals(BigDecimal("1000.0"), approve?.approvedAmounts?.first()?.approvedAmount)
|
||||
assertTrue(approve?.approvedAmounts?.first()?.isUnlimited == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseBenignValidationAndSuccessSimulationThenReturnsSendReceiveResult() {
|
||||
val assetDiff = AssetDiff(
|
||||
assetType = "ERC20",
|
||||
asset = Asset(chainId = 1, logoUrl = "logo", symbol = "ETH"),
|
||||
inTransfer = listOf(Transfer(value = "2.0", rawValue = "0x1")),
|
||||
outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(exposures = emptyList(), assetsDiffs = listOf(assetDiff)),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(ValidationResult.SAFE, result.validation)
|
||||
|
||||
val simulation = result.simulation as? SimulationResult.Success
|
||||
assertNotNull(simulation)
|
||||
|
||||
val data = simulation?.data as? SimulationData.SendAndReceive
|
||||
assertNotNull(data)
|
||||
assertEquals(BigDecimal("1.5"), data?.send?.first()?.amount)
|
||||
assertEquals(BigDecimal("2.0"), data?.receive?.first()?.amount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseErrorValidationThenReturnsFailedToValidate() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Error", resultType = "Benign"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(ValidationResult.FAILED_TO_VALIDATE, result.validation)
|
||||
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseNotBenignThenReturnsValidationUnsafe() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Phishing"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertEquals(ValidationResult.UNSAFE, result.validation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseSimulationNotSuccessThenReturnsSimulationFailedToSimulate() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Error",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(response)
|
||||
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenResponseSimulationIsEmptyThenReturnsFailedToSimulate() {
|
||||
val txResponse = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
assetsDiffs = emptyList(),
|
||||
exposures = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.mapToDomain(txResponse)
|
||||
assertTrue(result.simulation is SimulationResult.FailedToSimulate)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.data.blockaid
|
||||
|
||||
import com.domain.blockaid.models.dapp.CheckDAppResult
|
||||
import com.domain.blockaid.models.dapp.DAppData
|
||||
import com.domain.blockaid.models.transaction.CheckTransactionResult
|
||||
import com.domain.blockaid.models.transaction.TransactionData
|
||||
import com.domain.blockaid.models.transaction.TransactionParams
|
||||
import com.tangem.datasource.api.common.blockaid.BlockAidApi
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultBlockAidRepositoryTest {
|
||||
|
||||
@MockK
|
||||
private lateinit var api: BlockAidApi
|
||||
|
||||
@MockK
|
||||
private lateinit var mapper: BlockAidMapper
|
||||
|
||||
@MockK
|
||||
private lateinit var dispatcherProvider: CoroutineDispatcherProvider
|
||||
|
||||
private lateinit var repository: DefaultBlockAidRepository
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockKAnnotations.init(this)
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { dispatcherProvider.io } returns testDispatcher
|
||||
repository = DefaultBlockAidRepository(api, dispatcherProvider, mapper)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenVerifyAppDomainThenCallsApiAndMapsResult() = runTest {
|
||||
val url = "https://example.com"
|
||||
val domainData = DAppData(url)
|
||||
val domainResponse = DomainScanResponse(status = "hit", isMalicious = false)
|
||||
val expectedResult = CheckDAppResult.SAFE
|
||||
|
||||
coEvery { api.scanDomain(DomainScanRequest(url)) } returns domainResponse
|
||||
every { mapper.mapToDomain(domainResponse) } returns expectedResult
|
||||
|
||||
val result = repository.verifyDAppDomain(domainData)
|
||||
|
||||
assertEquals(expectedResult, result)
|
||||
coVerify { api.scanDomain(DomainScanRequest(url)) }
|
||||
verify { mapper.mapToDomain(domainResponse) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenVerifyEvmTransactionThenCallsScanJsonRpcAndMaps() = runTest {
|
||||
val data = TransactionData(
|
||||
chain = "ethereum",
|
||||
accountAddress = "0xabc",
|
||||
domainUrl = "https://uniswap.org",
|
||||
method = "eth_sendTransaction",
|
||||
params = TransactionParams.Evm(params = "some-params"),
|
||||
)
|
||||
|
||||
val request = mockk<EvmTransactionScanRequest>()
|
||||
val response = mockk<TransactionScanResponse>()
|
||||
val expectedResult = mockk<CheckTransactionResult>()
|
||||
|
||||
every { mapper.mapToEvmRequest(data) } returns request
|
||||
coEvery { api.scanJsonRpc(request) } returns response
|
||||
every { mapper.mapToDomain(response) } returns expectedResult
|
||||
|
||||
val result = repository.verifyTransaction(data)
|
||||
|
||||
assertEquals(expectedResult, result)
|
||||
coVerify { api.scanJsonRpc(request) }
|
||||
verify { mapper.mapToEvmRequest(data) }
|
||||
verify { mapper.mapToDomain(response) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenVerifySolanaTransactionThenCallsScanSolanaMessageAndMapsResult() = runTest {
|
||||
val data = TransactionData(
|
||||
chain = "mainnet",
|
||||
accountAddress = "/Rd2TLl...",
|
||||
domainUrl = "https://example.com",
|
||||
method = "signTransaction",
|
||||
params = TransactionParams.Solana(transactions = listOf("TX_PAYLOAD_BASE64")),
|
||||
)
|
||||
|
||||
val request = mockk<SolanaTransactionScanRequest>()
|
||||
val response = mockk<TransactionScanResponse>()
|
||||
val expectedResult = mockk<CheckTransactionResult>()
|
||||
|
||||
every { mapper.mapToSolanaRequest(data) } returns request
|
||||
coEvery { api.scanSolanaMessage(request) } returns response
|
||||
every { mapper.mapToDomain(response) } returns expectedResult
|
||||
|
||||
val result = repository.verifyTransaction(data)
|
||||
|
||||
assertEquals(expectedResult, result)
|
||||
coVerify { api.scanSolanaMessage(request) }
|
||||
verify { mapper.mapToSolanaRequest(data) }
|
||||
verify { mapper.mapToDomain(response) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
|
|||
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
|
||||
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
import com.tangem.domain.nft.models.NFTCollection
|
||||
import com.tangem.domain.nft.models.NFTCollections
|
||||
import com.tangem.domain.nft.models.NFTSalePrice
|
||||
|
|
@ -117,7 +118,7 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
assets.forEach {
|
||||
val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier)
|
||||
val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId)
|
||||
if (price is NFTSalePrice.Error) {
|
||||
if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) {
|
||||
refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier)
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +156,12 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
|
||||
override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs()
|
||||
|
||||
override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? =
|
||||
walletManagersFacade.getNFTExploreUrl(
|
||||
network = network,
|
||||
assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier),
|
||||
)
|
||||
|
||||
private suspend fun refreshSalePrice(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
|
|
|
|||
1
data/quotes/.gitignore
vendored
Normal file
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,158 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.data.staking.store.YieldsBalancesStore.StakingID
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.staking.model.stakekit.YieldBalance
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal typealias WalletIdWithWrappers = Map<String, Set<YieldBalanceWrapperDTO>>
|
||||
internal typealias WalletIdWithBalances = Map<UserWalletId, Set<YieldBalance>>
|
||||
|
||||
/**
|
||||
* Default implementation of [YieldsBalancesStore]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceStore persistence store
|
||||
* @param dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultYieldsBalancesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithBalances>,
|
||||
private val persistenceStore: DataStore<WalletIdWithWrappers>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : YieldsBalancesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch
|
||||
|
||||
runtimeStore.store(
|
||||
value = cachedStatuses.map { (stringWalletId, wrappers) ->
|
||||
val key = UserWalletId(stringWalletId)
|
||||
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
|
||||
|
||||
key to value
|
||||
}
|
||||
.toMap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>> {
|
||||
return runtimeStore.get().mapNotNull { it[userWalletId] }
|
||||
}
|
||||
|
||||
override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) {
|
||||
updateBalanceInRuntime(userWalletId, stakingId) {
|
||||
it.copySealed(source = StatusSource.CACHE)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
runtimeStore.update(default = emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
val storedBalances = this[userWalletId].orEmpty()
|
||||
|
||||
val balances = stakingIds.mapTo(hashSetOf()) { stakingId ->
|
||||
val balance = storedBalances.firstOrNull {
|
||||
it.integrationId == stakingId.integrationId &&
|
||||
it.address == stakingId.address
|
||||
}
|
||||
?: createDefaultBalance(id = stakingId)
|
||||
|
||||
balance.copySealed(source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
val updatedBalances = storedBalances.addOrReplace(balances) { old, new ->
|
||||
old.integrationId == new.integrationId && old.address == new.address
|
||||
}
|
||||
|
||||
put(key = userWalletId, value = updatedBalances)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
|
||||
coroutineScope {
|
||||
launch { storeInRuntime(userWalletId = userWalletId, values = values) }
|
||||
launch { storeInPersistence(userWalletId = userWalletId, values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID) {
|
||||
updateBalanceInRuntime(userWalletId, stakingId) {
|
||||
it.copySealed(source = StatusSource.ONLY_CACHE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
|
||||
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
|
||||
|
||||
runtimeStore.update(default = emptyMap()) { saved ->
|
||||
saved.toMutableMap().apply {
|
||||
this[userWalletId] = saved[userWalletId]
|
||||
?.addOrReplace(newBalances) { old, new ->
|
||||
old.integrationId == new.integrationId && old.address == new.address
|
||||
}
|
||||
?: newBalances
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
|
||||
persistenceStore.updateData { current ->
|
||||
current.toMutableMap().apply {
|
||||
this[userWalletId.stringValue] = current[userWalletId.stringValue]
|
||||
?.addOrReplace(items = values) { old, new ->
|
||||
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
|
||||
}
|
||||
?: values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateBalanceInRuntime(
|
||||
userWalletId: UserWalletId,
|
||||
stakingID: StakingID,
|
||||
update: (YieldBalance) -> YieldBalance,
|
||||
) {
|
||||
runtimeStore.update(default = emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
val balance = this[userWalletId].orEmpty()
|
||||
.firstOrNull {
|
||||
it.integrationId == stakingID.integrationId &&
|
||||
it.address == stakingID.address
|
||||
}
|
||||
?: createDefaultBalance(id = stakingID)
|
||||
|
||||
val updatedBalances = this[userWalletId].orEmpty()
|
||||
.addOrReplace(item = update(balance)) {
|
||||
it.integrationId == balance.integrationId &&
|
||||
it.address == balance.address
|
||||
}
|
||||
|
||||
put(key = userWalletId, value = updatedBalances)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultBalance(id: StakingID): YieldBalance {
|
||||
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.domain.staking.model.stakekit.YieldBalance
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Store of [YieldBalance]'s set
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface YieldsBalancesStore {
|
||||
|
||||
/** Get flow of [YieldBalance]'s set by [userWalletId] */
|
||||
fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>>
|
||||
|
||||
/** Refresh balance of [stakingId] by [userWalletId] */
|
||||
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
|
||||
|
||||
/** Refresh balances of [stakingIds] by [userWalletId] */
|
||||
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
|
||||
/** Store actual [values] by [userWalletId] */
|
||||
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
|
||||
|
||||
/** Store error by [userWalletId] and [stakingId] */
|
||||
suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID)
|
||||
|
||||
data class StakingID(val integrationId: String, val address: String)
|
||||
}
|
||||
|
|
@ -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,190 @@
|
|||
package com.tangem.data.staking.multi
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.staking.store.YieldsBalancesStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
import com.tangem.domain.staking.model.stakekit.YieldBalance
|
||||
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiYieldBalanceProducerTest {
|
||||
|
||||
private val params = MultiYieldBalanceProducer.Params(userWalletId = UserWalletId("011"))
|
||||
|
||||
private val yieldsBalancesStore = mockk<YieldsBalancesStore>()
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
|
||||
private val producer = DefaultMultiYieldBalanceProducer(
|
||||
params = params,
|
||||
yieldsBalancesStore = yieldsBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test that flow is mapped for user wallet id from params`() = runTest {
|
||||
val balances = setOf(
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
|
||||
)
|
||||
|
||||
val networksStatusesFlow = flowOf(balances)
|
||||
|
||||
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { yieldsBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values.first()).isEqualTo(balances)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that flow is updated if balances are updated`() = runTest {
|
||||
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
|
||||
|
||||
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { yieldsBalancesStore.get(params.userWalletId) }
|
||||
|
||||
// first emit
|
||||
val balances = setOf(
|
||||
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
|
||||
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
|
||||
)
|
||||
|
||||
networksStatusesFlow.emit(balances)
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
Truth.assertThat(values1.first()).isEqualTo(balances)
|
||||
|
||||
// second emit
|
||||
val updatedWrappers = setOf(
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
|
||||
)
|
||||
|
||||
networksStatusesFlow.emit(updatedWrappers)
|
||||
|
||||
val values2 = getEmittedValues(flow = actual)
|
||||
|
||||
val expected = listOf(balances, updatedWrappers)
|
||||
Truth.assertThat(values2.size).isEqualTo(2)
|
||||
Truth.assertThat(values2).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that flow is filtered the same balance`() = runTest {
|
||||
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
|
||||
|
||||
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { yieldsBalancesStore.get(params.userWalletId) }
|
||||
|
||||
// first emit
|
||||
val wrappers = setOf(
|
||||
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
|
||||
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
|
||||
)
|
||||
|
||||
networksStatusesFlow.emit(wrappers)
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
Truth.assertThat(values1.first()).isEqualTo(wrappers)
|
||||
|
||||
// second emit
|
||||
networksStatusesFlow.emit(wrappers)
|
||||
|
||||
val values2 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values2.size).isEqualTo(1)
|
||||
Truth.assertThat(values2.first()).isEqualTo(wrappers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test if flow throws exception`() = runTest {
|
||||
val exception = IllegalStateException()
|
||||
val balances = setOf(
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
|
||||
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
|
||||
)
|
||||
|
||||
val innerFlow = MutableStateFlow(value = false)
|
||||
val networksStatusesFlow = flow {
|
||||
if (innerFlow.value) {
|
||||
emit(balances)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.buffer(capacity = 5)
|
||||
|
||||
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { yieldsBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(emptySet<YieldBalance>()))
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
||||
val values2 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values2.size).isEqualTo(1)
|
||||
Truth.assertThat(values2).isEqualTo(listOf(balances))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that flow is empty`() = runTest {
|
||||
every { yieldsBalancesStore.get(params.userWalletId) } returns emptyFlow()
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { yieldsBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
|
||||
val solanaId = YieldsBalancesStore.StakingID(
|
||||
integrationId = "solana-sol-native-multivalidator-staking",
|
||||
address = "0x1",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,188 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.staking.model.stakekit.YieldBalance
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class YieldsBalancesStoreUpdateMethodsTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
|
||||
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
|
||||
|
||||
private val store = DefaultYieldsBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `refresh the single id if runtime store is empty`() = runTest {
|
||||
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(
|
||||
YieldBalance.Error(
|
||||
integrationId = stakingId.integrationId,
|
||||
address = stakingId.address,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh the single id if runtime store contains balance with this id`() = runTest {
|
||||
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance().toDomain(source = StatusSource.ACTUAL)
|
||||
|
||||
runtimeStore.store(
|
||||
value = mapOf(userWalletId to setOf(balance)),
|
||||
)
|
||||
|
||||
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(
|
||||
balance.copySealed(source = StatusSource.CACHE),
|
||||
),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh the multi ids if runtime store is empty`() = runTest {
|
||||
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to stakingIds.mapTo(hashSetOf()) {
|
||||
YieldBalance.Error(integrationId = it.integrationId, address = it.address)
|
||||
},
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh the multi ids if runtime store contains balance with this id`() = runTest {
|
||||
val firstBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.first())
|
||||
.toDomain(source = StatusSource.ACTUAL)
|
||||
|
||||
val secondBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.last())
|
||||
.toDomain(source = StatusSource.ACTUAL)
|
||||
|
||||
runtimeStore.store(
|
||||
value = mapOf(userWalletId to setOf(firstBalance, secondBalance)),
|
||||
)
|
||||
|
||||
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(
|
||||
firstBalance.copySealed(source = StatusSource.CACHE),
|
||||
secondBalance.copySealed(source = StatusSource.CACHE),
|
||||
),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store actual if runtime and cache stores contain balance with this id`() = runTest {
|
||||
val prevWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
|
||||
|
||||
runtimeStore.store(
|
||||
value = mapOf(userWalletId to setOf(prevWrapper.toDomain(source = StatusSource.CACHE))),
|
||||
)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
put(userWalletId.stringValue, setOf(prevWrapper))
|
||||
}
|
||||
}
|
||||
|
||||
val newWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
|
||||
|
||||
store.storeActual(userWalletId, setOf(newWrapper))
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(newWrapper.toDomain(source = StatusSource.ACTUAL)),
|
||||
)
|
||||
|
||||
val persistenceExpected = mapOf(
|
||||
userWalletId.stringValue to setOf(newWrapper),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store is empty`() = runTest {
|
||||
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(
|
||||
YieldBalance.Error(
|
||||
integrationId = stakingId.integrationId,
|
||||
address = stakingId.address,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store contains balance with this id`() = runTest {
|
||||
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId)
|
||||
|
||||
runtimeStore.store(
|
||||
value = mapOf(
|
||||
userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE)),
|
||||
),
|
||||
)
|
||||
|
||||
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
|
||||
|
||||
val runtimeExpected = mapOf(
|
||||
userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val userWalletId = UserWalletId(stringValue = "011")
|
||||
|
||||
val stakingId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
|
||||
|
||||
val stakingIds = setOf(
|
||||
stakingId,
|
||||
YieldsBalancesStore.StakingID(
|
||||
integrationId = "solana-sol-native-multivalidator-staking",
|
||||
address = "0x1",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,27 @@ internal class DefaultTransactionRepository(
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prepareForSend(
|
||||
transactionData: TransactionData,
|
||||
signer: TransactionSigner,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
): Result<ByteArray> = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = blockchain,
|
||||
derivationPath = network.derivationPath.value,
|
||||
)
|
||||
val preparer = walletManager as? TransactionPreparer ?: kotlin.run {
|
||||
Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder")
|
||||
error("Wallet manager does not support TransactionPreparer")
|
||||
}
|
||||
|
||||
when (val prepareForSend = preparer.prepareForSend(transactionData, signer)) {
|
||||
is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error)
|
||||
is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,60 @@
|
|||
package com.tangem.data.visa.converter
|
||||
|
||||
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
|
||||
import com.tangem.domain.visa.model.VisaActivationOrderInfo
|
||||
import com.tangem.domain.visa.model.VisaActivationRemoteState
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VisaActivationStatusConverter @Inject constructor() :
|
||||
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
|
||||
|
||||
private val lastUpdatedAt = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
|
||||
// handle pin code error
|
||||
// either we entered pin code before with an error (WasError) or after receiving an error (InProgress)
|
||||
if (
|
||||
value.status == Status.AwaitingPin.stringValue &&
|
||||
value.stepChangeCode != null &&
|
||||
value.stepChangeCode == PIN_CODE_VALIDATION_ERROR
|
||||
) {
|
||||
return if (lastUpdatedAt.value != value.updatedAt) {
|
||||
lastUpdatedAt.value == value.updatedAt
|
||||
|
||||
VisaActivationRemoteState.AwaitingPinCode(
|
||||
activationOrderInfo = value.activationOrder!!.convert(),
|
||||
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
|
||||
)
|
||||
} else {
|
||||
VisaActivationRemoteState.AwaitingPinCode(
|
||||
activationOrderInfo = value.activationOrder!!.convert(),
|
||||
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Will be implemented in the future
|
||||
return VisaActivationRemoteState.Activated
|
||||
}
|
||||
|
||||
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
|
||||
return VisaActivationOrderInfo(
|
||||
orderId = id,
|
||||
customerId = customerId,
|
||||
customerWalletAddress = customerWalletAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private enum class Status(val stringValue: String) {
|
||||
AwaitingPin("AWAITING_PIN"),
|
||||
// TODO complete statuses list when backend is ready
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PIN_CODE_VALIDATION_ERROR = 1000
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ dependencies {
|
|||
/* Project - Domain */
|
||||
implementation(projects.domain.walletConnect)
|
||||
implementation(projects.domain.walletConnect.models)
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.transaction.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.tokens)
|
||||
|
|
@ -35,6 +37,7 @@ dependencies {
|
|||
|
||||
/* Tangem libraries */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/* Reown - WalletConnect */
|
||||
implementation(deps.reownCore) {
|
||||
|
|
@ -47,4 +50,11 @@ dependencies {
|
|||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
}
|
||||
|
|
@ -5,27 +5,25 @@ import com.squareup.moshi.Moshi
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
|
||||
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
|
||||
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
|
||||
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
|
||||
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
|
||||
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
|
||||
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
|
||||
import com.tangem.data.walletconnect.request.DefaultWcRequestService
|
||||
import com.tangem.data.walletconnect.request.WcMethodHandler
|
||||
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
|
||||
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.datasource.di.SdkMoshi
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
|
||||
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.request.WcRequestService
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
|
|
@ -58,12 +56,12 @@ internal object WalletConnectDataModule {
|
|||
application: Application,
|
||||
sessionsManager: DefaultWcSessionsManager,
|
||||
networkService: DefaultWcRequestService,
|
||||
wcPairFlow: DefaultWcPairUseCase,
|
||||
pairSdkDelegate: WcPairSdkDelegate,
|
||||
): WcInitializeUseCase = DefaultWcInitializeUseCase(
|
||||
application = application,
|
||||
sessionsManager = sessionsManager,
|
||||
networkService = networkService,
|
||||
wcPairFlow = wcPairFlow,
|
||||
pairSdkDelegate = pairSdkDelegate,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
@ -72,16 +70,22 @@ internal object WalletConnectDataModule {
|
|||
sessionsManager: WcSessionsManager,
|
||||
associateNetworksDelegate: AssociateNetworksDelegate,
|
||||
caipNamespaceDelegate: CaipNamespaceDelegate,
|
||||
sdkDelegate: WcPairSdkDelegate,
|
||||
): DefaultWcPairUseCase = DefaultWcPairUseCase(
|
||||
sessionsManager = sessionsManager,
|
||||
associateNetworksDelegate = associateNetworksDelegate,
|
||||
caipNamespaceDelegate = caipNamespaceDelegate,
|
||||
sdkDelegate = sdkDelegate,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun defaultWcSessionsManager(
|
||||
|
|
@ -107,38 +111,39 @@ internal object WalletConnectDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun defaultWcRequestService(
|
||||
sessionsManager: WcSessionsManager,
|
||||
respondService: WcRespondService,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
diHelperBox: DiHelperBox,
|
||||
): DefaultWcRequestService {
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
|
||||
return DefaultWcRequestService(
|
||||
sessionsManager = sessionsManager,
|
||||
respondService = respondService,
|
||||
requestAdapters = diHelperBox.handlers,
|
||||
requestConverters = diHelperBox.handlers,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcRespondService(): WcRespondService = DefaultWcRespondService()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
|
||||
fun wcEthNetwork(
|
||||
@SdkMoshi moshi: Moshi,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
sessionsManager: WcSessionsManager,
|
||||
factories: WcEthNetwork.Factories,
|
||||
): WcEthNetwork = WcEthNetwork(
|
||||
moshi = moshi,
|
||||
respondService = respondService,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
sessionsManager = sessionsManager,
|
||||
factories = factories,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork()
|
||||
fun wcSolanaNetwork(excludedBlockchains: ExcludedBlockchains): WcSolanaNetwork = WcSolanaNetwork(
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -156,12 +161,10 @@ internal object WalletConnectDataModule {
|
|||
diHelperBox: DiHelperBox,
|
||||
getWallets: GetWalletsUseCase,
|
||||
currenciesRepository: CurrenciesRepository,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
): AssociateNetworksDelegate = AssociateNetworksDelegate(
|
||||
namespaceConverters = diHelperBox.converters,
|
||||
getWallets = getWallets,
|
||||
currenciesRepository = currenciesRepository,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
@ -170,14 +173,14 @@ internal object WalletConnectDataModule {
|
|||
handlers = setOf(
|
||||
ethNetwork,
|
||||
),
|
||||
converters = buildMap {
|
||||
ethNetwork.namespaceKey to ethNetwork
|
||||
solanaNetwork.namespaceKey to solanaNetwork
|
||||
},
|
||||
converters = setOf(
|
||||
ethNetwork,
|
||||
solanaNetwork,
|
||||
),
|
||||
)
|
||||
|
||||
internal class DiHelperBox(
|
||||
val converters: Map<NamespaceKey, WcNamespaceConverter>,
|
||||
val handlers: Set<WcMethodHandler<WcMethod>>,
|
||||
val converters: Set<WcNamespaceConverter>,
|
||||
val handlers: Set<WcRequestToUseCaseConverter>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,102 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
|
||||
import com.tangem.blockchain.common.HEX_PREFIX
|
||||
import com.tangem.blockchain.extensions.isAscii
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.common.extensions.toDecompressedPublicKey
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.OnSign
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMessageSignUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
internal class DefaultWcEthMessageSignUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.MessageSign,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val signUseCase: SignUseCase,
|
||||
) : BaseWcSignUseCase<Nothing, WcEthMessageSignUseCase.SignModel>(),
|
||||
WcEthMessageSignUseCase {
|
||||
|
||||
override val onSign: OnSign<WcEthMessageSignUseCase.SignModel> = collector@{ state ->
|
||||
val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg)
|
||||
val userWallet = session.wallet
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
|
||||
?: return@collector
|
||||
|
||||
val signedHash = signUseCase(hashToSign, userWallet, network)
|
||||
.onLeft { emit(state.toResult(it.left())) }
|
||||
.getOrNull() ?: return@collector
|
||||
|
||||
val respond = EthereumUtils.prepareSignedMessageData(
|
||||
signedHash = signedHash,
|
||||
hashToSign = hashToSign,
|
||||
publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(),
|
||||
)
|
||||
|
||||
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
|
||||
emit(state.toResult(wcRespondResult))
|
||||
}
|
||||
|
||||
override fun invoke(): Flow<WcSignState<WcEthMessageSignUseCase.SignModel>> = flow {
|
||||
val model = WcEthMessageSignUseCase.SignModel(
|
||||
rawMsg = method.message,
|
||||
account = method.account,
|
||||
humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(),
|
||||
)
|
||||
emitAll(delegate(model))
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): DefaultWcEthMessageSignUseCase
|
||||
}
|
||||
}
|
||||
|
||||
object LegacySdkHelper {
|
||||
private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
|
||||
|
||||
fun createMessageData(message: String): ByteArray {
|
||||
val messageData = try {
|
||||
message.removePrefix(HEX_PREFIX).hexToBytes()
|
||||
} catch (exception: Exception) {
|
||||
message.asciiToHex()?.hexToBytes() ?: byteArrayOf()
|
||||
}
|
||||
|
||||
val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray()
|
||||
return (prefixData + messageData).toKeccak()
|
||||
}
|
||||
|
||||
fun hexToAscii(hex: String): String? {
|
||||
return try {
|
||||
hex.removePrefix(HEX_PREFIX).hexToBytes().map {
|
||||
val char = it.toInt().toChar()
|
||||
if (char.isAscii()) char else return null
|
||||
}.joinToString("")
|
||||
} catch (exception: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.asciiToHex(): String? {
|
||||
return map {
|
||||
if (!it.isAscii()) return null
|
||||
Integer.toHexString(it.code)
|
||||
}.joinToString("")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.common.HEX_PREFIX
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.OnSign
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSendTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal class DefaultWcEthSendTransactionUseCase @AssistedInject constructor(
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.SendTransaction,
|
||||
override val respondService: WcRespondService,
|
||||
private val sendTransaction: SendTransactionUseCase,
|
||||
) : BaseWcSignUseCase<Nothing, TransactionData>(),
|
||||
WcEthSendTransactionUseCase {
|
||||
|
||||
override val onSign: OnSign<TransactionData> = collector@{ state ->
|
||||
val hash = sendTransaction(state.signModel, wallet, network)
|
||||
.onLeft { error ->
|
||||
val sendError = IllegalArgumentException(error.toString()) // todo(wc) use domain error
|
||||
emit(state.toResult(sendError.left()))
|
||||
}
|
||||
.getOrNull() ?: return@collector
|
||||
val respondHash = if (hash.startsWith(HEX_PREFIX)) hash else HEX_PREFIX + hash
|
||||
val respondResult = respondService.respond(rawSdkRequest, respondHash)
|
||||
emit(state.toResult(respondResult))
|
||||
}
|
||||
|
||||
override fun updateFee(fee: TransactionFee) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun invoke(): Flow<WcSignState<TransactionData>> {
|
||||
method
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
context: WcMethodUseCaseContext,
|
||||
method: WcEthMethod.SendTransaction,
|
||||
): DefaultWcEthSendTransactionUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.common.HEX_PREFIX
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.OnSign
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSignTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal class DefaultWcEthSignTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
private val prepareForSend: PrepareForSendUseCase,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted private val method: WcEthMethod.SignTransaction,
|
||||
) : BaseWcSignUseCase<Nothing, TransactionData>(),
|
||||
WcEthSignTransactionUseCase {
|
||||
|
||||
override val onSign: OnSign<TransactionData> = collector@{ state ->
|
||||
val hash = prepareForSend(state.signModel, wallet, network)
|
||||
.onLeft { error ->
|
||||
emit(state.toResult(error.left()))
|
||||
}
|
||||
.getOrNull() ?: return@collector
|
||||
val hashString = hash.toHexString()
|
||||
val respondHash = if (hashString.startsWith(HEX_PREFIX)) hashString else HEX_PREFIX + hashString
|
||||
val respondResult = respondService.respond(rawSdkRequest, respondHash)
|
||||
emit(state.toResult(respondResult))
|
||||
}
|
||||
|
||||
override fun updateFee(fee: TransactionFee) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun invoke(): Flow<WcSignState<TransactionData>> {
|
||||
method
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
context: WcMethodUseCaseContext,
|
||||
method: WcEthMethod.SignTransaction,
|
||||
): DefaultWcEthSignTransactionUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
package com.tangem.data.walletconnect.network.solana
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.walletconnect.model.CAIP2
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
|
||||
internal class WcSolanaNetwork constructor(
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
) : WcNamespaceConverter {
|
||||
|
||||
internal class WcSolanaNetwork : WcNamespaceConverter {
|
||||
override val namespaceKey: NamespaceKey = NamespaceKey("solana")
|
||||
|
||||
override fun toBlockchain(chainId: CAIP2): Blockchain? {
|
||||
|
|
@ -17,7 +23,12 @@ internal class WcSolanaNetwork : WcNamespaceConverter {
|
|||
}
|
||||
}
|
||||
|
||||
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
|
||||
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
|
||||
return toNetwork(chainId, wallet, excludedBlockchains)
|
||||
}
|
||||
|
||||
override fun toCAIP2(network: Network): CAIP2? {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val chainId = when (blockchain) {
|
||||
Blockchain.Solana -> MAINNET_CHAIN_ID
|
||||
Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import arrow.core.Either
|
|||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
|
|
@ -15,15 +13,13 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
|
|||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox")
|
||||
|
||||
|
|
@ -31,27 +27,18 @@ internal class DefaultWcPairUseCase(
|
|||
private val sessionsManager: WcSessionsManager,
|
||||
private val associateNetworksDelegate: AssociateNetworksDelegate,
|
||||
private val caipNamespaceDelegate: CaipNamespaceDelegate,
|
||||
) : WcPairUseCase, WcSdkObserver {
|
||||
private val sdkDelegate: WcPairSdkDelegate,
|
||||
) : WcPairUseCase {
|
||||
|
||||
private val onCallTerminalAction = Channel<TerminalAction>()
|
||||
private val onSessionProposal =
|
||||
Channel<Pair<Wallet.Model.SessionProposal, Wallet.Model.VerifyContext>>(Channel.BUFFERED)
|
||||
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>(Channel.BUFFERED)
|
||||
|
||||
override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow<WcPairState> {
|
||||
return flow {
|
||||
emit(WcPairState.Loading)
|
||||
|
||||
// call sdk.pair and wait result, finish flow on error
|
||||
walletKitPair(uri).onLeft { throwable ->
|
||||
emit(WcPairState.Error(WcPairError.Unknown(throwable.localizedMessage.orEmpty())))
|
||||
return@flow
|
||||
}
|
||||
// wait for sdk onSessionProposal callback
|
||||
val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow()
|
||||
.first { (sessionProposal, verifyContext) ->
|
||||
true // todo(wc) check verifyContext? compare uri?
|
||||
}
|
||||
val sdkSessionProposal = sdkDelegate.pair(uri)
|
||||
.onLeft { emit(WcPairState.Error(it)) }
|
||||
.getOrNull() ?: return@flow
|
||||
|
||||
// check unsupported dApps, just local constant for now, finish if unsupported
|
||||
if (sdkSessionProposal.name in unsupportedDApps) {
|
||||
|
|
@ -62,44 +49,32 @@ internal class DefaultWcPairUseCase(
|
|||
}
|
||||
|
||||
val proposalState = buildProposalState(sdkSessionProposal)
|
||||
.fold(ifLeft = { WcPairState.Error(it) }, ifRight = { it })
|
||||
.onLeft { emit(WcPairState.Error(it)) }
|
||||
.getOrNull() ?: return@flow
|
||||
emit(proposalState)
|
||||
|
||||
// wait first terminal action and continue WC pair flow
|
||||
val terminalAction = onCallTerminalAction.receiveAsFlow().first()
|
||||
val sessionForApprove: WcSessionApprove? = when (terminalAction) {
|
||||
is TerminalAction.Approve -> terminalAction.sessionForApprove
|
||||
TerminalAction.Reject -> {
|
||||
// non suspending WalletKit.rejectSession call
|
||||
rejectSession(sdkSessionProposal.proposerPublicKey)
|
||||
null
|
||||
}
|
||||
TerminalAction.Reject -> null
|
||||
}
|
||||
// finish flow if rejected above
|
||||
sessionForApprove ?: return@flow
|
||||
if (sessionForApprove == null) {
|
||||
sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey)
|
||||
return@flow
|
||||
}
|
||||
|
||||
// start flow of approving in wc sdk
|
||||
emit(WcPairState.Approving.Loading(sessionForApprove))
|
||||
// call sdk approve and wait result
|
||||
val either = walletKitApproveSession(
|
||||
sessionForApprove = sessionForApprove,
|
||||
sdkSessionProposal = sdkSessionProposal,
|
||||
).fold(
|
||||
ifLeft = { WcPairError.ExternalApprovalError(it.localizedMessage.orEmpty()).left() },
|
||||
ifRight = {
|
||||
when (val settledSession = onSessionSettleResponse.receiveAsFlow().first()) {
|
||||
is Wallet.Model.SettledSessionResponse.Error -> WcPairError.ExternalApprovalError(
|
||||
settledSession.errorMessage,
|
||||
).left()
|
||||
|
||||
is Wallet.Model.SettledSessionResponse.Result -> {
|
||||
val newSession = settledSession.session.toDomain(sessionForApprove.walletId)
|
||||
sessionsManager.saveSession(sessionForApprove.walletId, newSession)
|
||||
newSession.right()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
).map { settledSession ->
|
||||
val newSession = settledSession.session.toDomain(sessionForApprove.wallet)
|
||||
sessionsManager.saveSession(newSession)
|
||||
newSession
|
||||
}
|
||||
emit(WcPairState.Approving.Result(sessionForApprove, either))
|
||||
}
|
||||
}
|
||||
|
|
@ -112,77 +87,20 @@ internal class DefaultWcPairUseCase(
|
|||
onCallTerminalAction.trySend(TerminalAction.Reject)
|
||||
}
|
||||
|
||||
override fun onSessionProposal(
|
||||
sessionProposal: Wallet.Model.SessionProposal,
|
||||
verifyContext: Wallet.Model.VerifyContext,
|
||||
) {
|
||||
// Triggered when wallet receives the session proposal sent by a Dapp
|
||||
Timber.i("sessionProposal: $sessionProposal")
|
||||
onSessionProposal.trySend(sessionProposal to verifyContext)
|
||||
}
|
||||
|
||||
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
|
||||
// Triggered when wallet receives the session settlement response from Dapp
|
||||
Timber.i("onSessionSettleResponse: $settleSessionResponse")
|
||||
onSessionSettleResponse.trySend(settleSessionResponse)
|
||||
}
|
||||
|
||||
private suspend fun walletKitPair(uri: String): Either<Throwable, Unit> =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
WalletKit.pair(
|
||||
params = Wallet.Params.Pair(uri),
|
||||
onSuccess = {
|
||||
Timber.i("Paired successfully: $it")
|
||||
continuation.resume(Unit.right())
|
||||
},
|
||||
onError = {
|
||||
Timber.e("Error while pairing: $it")
|
||||
continuation.resume(it.throwable.left())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun walletKitApproveSession(
|
||||
sessionForApprove: WcSessionApprove,
|
||||
sdkSessionProposal: Wallet.Model.SessionProposal,
|
||||
): Either<Throwable, Unit> {
|
||||
): Either<WcPairError, Wallet.Model.SettledSessionResponse.Result> {
|
||||
val namespaces = caipNamespaceDelegate.associate(
|
||||
sdkSessionProposal,
|
||||
sessionForApprove.walletId,
|
||||
sessionForApprove.network.map { it.network },
|
||||
sessionForApprove.wallet,
|
||||
sessionForApprove.network,
|
||||
)
|
||||
val sessionApprove = Wallet.Params.SessionApprove(
|
||||
proposerPublicKey = sdkSessionProposal.proposerPublicKey,
|
||||
namespaces = namespaces,
|
||||
)
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
WalletKit.approveSession(
|
||||
params = sessionApprove,
|
||||
onSuccess = {
|
||||
Timber.i("Approved successfully: $it")
|
||||
continuation.resume(Unit.right())
|
||||
},
|
||||
onError = {
|
||||
Timber.e("Error while approving: $it")
|
||||
continuation.resume(it.throwable.left())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectSession(proposerPublicKey: String) {
|
||||
WalletKit.rejectSession(
|
||||
params = Wallet.Params.SessionReject(
|
||||
proposerPublicKey = proposerPublicKey,
|
||||
reason = "",
|
||||
),
|
||||
onSuccess = {
|
||||
Timber.i("Rejected successfully: $it")
|
||||
},
|
||||
onError = {
|
||||
Timber.e("Error while rejecting: $it")
|
||||
},
|
||||
)
|
||||
return sdkDelegate.approve(sessionApprove)
|
||||
}
|
||||
|
||||
private suspend fun buildProposalState(
|
||||
|
|
@ -209,8 +127,8 @@ internal class DefaultWcPairUseCase(
|
|||
}
|
||||
},)
|
||||
|
||||
private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession(
|
||||
userWalletId = walletId,
|
||||
private fun Wallet.Model.Session.toDomain(wallet: UserWallet): WcSession = WcSession(
|
||||
wallet = wallet,
|
||||
sdkModel = WcSdkSessionConverter.convert(this),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "")
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import com.tangem.domain.walletconnect.model.WcSession
|
|||
import com.tangem.domain.walletconnect.model.WcSessionDTO
|
||||
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -36,20 +36,22 @@ internal class DefaultWcSessionsManager constructor(
|
|||
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
|
||||
private val oneTimeMigration = MutableStateFlow(true)
|
||||
|
||||
override val sessions: Flow<Map<UserWalletId, List<WcSession>>>
|
||||
get() = store.sessions
|
||||
.transform { inStore ->
|
||||
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
|
||||
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
|
||||
.transform { pair ->
|
||||
val (wallets, inStore) = pair
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
if (oneTimeMigration.value) {
|
||||
oneTimeMigration.value = false
|
||||
val someMigrated = migrateLegacyStore(inStore)
|
||||
val someMigrated = migrateLegacyStore(inStore, inSdk, wallets)
|
||||
if (someMigrated) return@transform // ignore emit, wait next one
|
||||
}
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
val associatedSessions: List<WcSession> = associateWithSdk(inSdk, inStore)
|
||||
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
|
||||
val someRemove = removeUnknownSessions(inStore, associatedSessions)
|
||||
if (someRemove) return@transform // ignore emit, wait next one
|
||||
emit(associatedSessions.groupBy { it.userWalletId })
|
||||
emit(associatedSessions.groupBy { it.wallet })
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.io)
|
||||
|
||||
override fun onWcSdkInit() {
|
||||
|
|
@ -57,11 +59,11 @@ internal class DefaultWcSessionsManager constructor(
|
|||
listenOnSessionDelete()
|
||||
}
|
||||
|
||||
override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId))
|
||||
override suspend fun saveSession(session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId))
|
||||
}
|
||||
|
||||
override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either<Throwable, Unit> {
|
||||
override suspend fun removeSession(session: WcSession): Either<Throwable, Unit> {
|
||||
val topic = session.sdkModel.topic
|
||||
val sdkCall = sdkDisconnectSession(topic)
|
||||
sdkCall.onLeft { return it.left() }
|
||||
|
|
@ -80,9 +82,13 @@ internal class DefaultWcSessionsManager constructor(
|
|||
}
|
||||
|
||||
override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) {
|
||||
val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null
|
||||
val storedSessions = sessions.firstOrNull()
|
||||
?.values?.flatten()
|
||||
?.firstOrNull { it.sdkModel.topic == topic }
|
||||
?: return@withContext null
|
||||
val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wallet = storedSessions.wallet
|
||||
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
}
|
||||
|
||||
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
|
||||
|
|
@ -90,28 +96,35 @@ internal class DefaultWcSessionsManager constructor(
|
|||
onSessionDelete.trySend(sessionDelete)
|
||||
}
|
||||
|
||||
private suspend fun migrateLegacyStore(inNewStoreSessions: Set<WcSessionDTO>): Boolean {
|
||||
val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId }
|
||||
val inLegacyStoreSessions = walletIds
|
||||
private suspend fun migrateLegacyStore(
|
||||
inNewStore: Set<WcSessionDTO>,
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
wallets: List<UserWallet>,
|
||||
): Boolean {
|
||||
val walletIds = wallets.map { wallet -> wallet.walletId }
|
||||
val inLegacyStore = walletIds
|
||||
.map { walletId ->
|
||||
flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) }
|
||||
}
|
||||
.merge()
|
||||
.reduce { accumulator, value -> accumulator.plus(value) }
|
||||
// migrate only active legacySessions
|
||||
.filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } }
|
||||
|
||||
val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions)
|
||||
val mustSaveInNewStore = inLegacyStore.subtract(inNewStore)
|
||||
if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore)
|
||||
return mustSaveInNewStore.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun associateWithSdk(
|
||||
sdkSessions: List<Wallet.Model.Session>,
|
||||
storeSessions: Set<WcSessionDTO>,
|
||||
private fun associate(
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
inStore: Set<WcSessionDTO>,
|
||||
wallets: List<UserWallet>,
|
||||
): List<WcSession> {
|
||||
val wcSessions = sdkSessions.mapNotNull { sdkSession ->
|
||||
val storedSessions = storeSessions.find { it.topic == sdkSession.topic }
|
||||
?: return@mapNotNull null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wcSessions = inStore.mapNotNull { session ->
|
||||
val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null
|
||||
val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null
|
||||
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
}
|
||||
return wcSessions
|
||||
}
|
||||
|
|
@ -122,9 +135,6 @@ internal class DefaultWcSessionsManager constructor(
|
|||
val haveSomeUnknown = unknownStoredSessions.isNotEmpty()
|
||||
|
||||
if (haveSomeUnknown) {
|
||||
unknownStoredSessions.forEach { unknown ->
|
||||
legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic)
|
||||
}
|
||||
store.removeSessions(unknownStoredSessions.toSet())
|
||||
}
|
||||
return haveSomeUnknown
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
internal abstract class BaseWcSignUseCase<MiddleAction, SignModel> :
|
||||
WcMethodUseCase,
|
||||
WcSignUseCase,
|
||||
FinalActionCollector<SignModel>,
|
||||
MiddleActionCollector<MiddleAction, SignModel> {
|
||||
|
||||
abstract val respondService: WcRespondService
|
||||
|
||||
abstract val context: WcMethodUseCaseContext
|
||||
override val network: Network get() = context.network
|
||||
override val session: WcSession get() = context.session
|
||||
override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest
|
||||
val wallet: UserWallet get() = session.wallet
|
||||
|
||||
protected val delegate by lazy {
|
||||
WcSignUseCaseDelegate(
|
||||
finalActionCollector = this,
|
||||
middleActionCollector = this,
|
||||
)
|
||||
}
|
||||
|
||||
override val onCancel: suspend (currentState: WcSignState<SignModel>) -> Unit = {
|
||||
defaultReject()
|
||||
}
|
||||
|
||||
override fun sign() = delegate.sign()
|
||||
override fun cancel() = delegate.cancel()
|
||||
protected fun middleAction(action: MiddleAction) = delegate.middleAction(action)
|
||||
|
||||
protected fun defaultReject() {
|
||||
respondService.rejectRequestNonBlock(rawSdkRequest)
|
||||
}
|
||||
}
|
||||
|
||||
internal interface MiddleActionCollector<MiddleAction, SignModel> {
|
||||
|
||||
val onMiddleAction: OnMiddle<MiddleAction, SignModel> get() = { _, _ -> }
|
||||
}
|
||||
|
||||
internal interface FinalActionCollector<SignModel> {
|
||||
|
||||
val onSign: OnSign<SignModel> get() = {}
|
||||
|
||||
val onCancel: OnCancel<SignModel> get() = {}
|
||||
}
|
||||
|
||||
internal class WcMethodUseCaseContext(
|
||||
val session: WcSession,
|
||||
val rawSdkRequest: WcSdkSessionRequest,
|
||||
val network: Network,
|
||||
)
|
||||
|
||||
internal typealias OnSign<SignModel> =
|
||||
suspend FlowCollector<WcSignState<SignModel>>.(state: WcSignState<SignModel>) -> Unit
|
||||
internal typealias OnCancel<SignModel> =
|
||||
suspend (currentState: WcSignState<SignModel>) -> Unit
|
||||
internal typealias OnMiddle<MiddleAction, SignModel> =
|
||||
suspend FlowCollector<SignModel>.(currentState: WcSignState<SignModel>, middleAction: MiddleAction) -> Unit
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
|
||||
object SignStateConverter {
|
||||
|
||||
internal fun <M> preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign)
|
||||
internal fun <M> signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing)
|
||||
internal fun <M> result(result: Either<Throwable, Unit>, signModel: M) =
|
||||
WcSignState(signModel, WcSignStep.Result(result))
|
||||
|
||||
internal fun <M> WcSignState<M>.toPreSign(signModel: M = this.signModel) = copy(
|
||||
signModel = signModel,
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
|
||||
internal fun <M> WcSignState<M>.toSigning(signModel: M = this.signModel) = copy(
|
||||
domainStep = WcSignStep.Signing,
|
||||
signModel = signModel,
|
||||
)
|
||||
|
||||
internal fun <M> WcSignState<M>.toResult(result: Either<Throwable, Unit>, signModel: M = this.signModel) = copy(
|
||||
domainStep = WcSignStep.Result(result),
|
||||
signModel = signModel,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class WcSignUseCaseDelegate<MiddleAction, SignModel>(
|
||||
private val finalActionCollector: FinalActionCollector<SignModel>,
|
||||
private val middleActionCollector: MiddleActionCollector<MiddleAction, SignModel>,
|
||||
) : FinalActionCollector<SignModel> by finalActionCollector,
|
||||
MiddleActionCollector<MiddleAction, SignModel> by middleActionCollector {
|
||||
|
||||
private val middleActionsChannel = Channel<MiddleAction>()
|
||||
private val finalActionsChannel = Channel<Action>()
|
||||
|
||||
fun cancel() {
|
||||
finalActionsChannel.trySend(Action.Cancel)
|
||||
}
|
||||
|
||||
fun sign() {
|
||||
finalActionsChannel.trySend(Action.Sign)
|
||||
}
|
||||
|
||||
fun middleAction(action: MiddleAction) {
|
||||
middleActionsChannel.trySend(action)
|
||||
}
|
||||
|
||||
operator fun invoke(initModel: SignModel) = channelFlow {
|
||||
val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign))
|
||||
|
||||
state
|
||||
.onEach { newState -> channel.send(newState) }
|
||||
.launchIn(this)
|
||||
|
||||
fun listenMiddle() = middleActionsChannel.receiveAsFlow()
|
||||
.buffer()
|
||||
.transform { middleActions -> this.onMiddleAction(state.value, middleActions) }
|
||||
.onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } }
|
||||
.launchIn(this)
|
||||
|
||||
var listenMiddleJob: Job = listenMiddle()
|
||||
|
||||
fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) }
|
||||
.onEach { newState -> state.update { newState } }
|
||||
.catch { exception ->
|
||||
val errorResult = state.value.toResult(exception.left())
|
||||
state.update { errorResult }
|
||||
}
|
||||
|
||||
var signJob: Job? = null
|
||||
|
||||
finalActionsChannel.receiveAsFlow()
|
||||
.transformLatest<Action, Unit> { finalAction ->
|
||||
when (finalAction) {
|
||||
Action.Cancel -> {
|
||||
onCancel.invoke(state.value)
|
||||
channel.close()
|
||||
}
|
||||
Action.Sign -> {
|
||||
val isSigningNow = signJob?.isActive == true
|
||||
if (isSigningNow) return@transformLatest
|
||||
listenMiddleJob.cancel()
|
||||
signJob = launch {
|
||||
signFlow().collect()
|
||||
listenMiddleJob = listenMiddle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
/**
|
||||
* keep flow running to attempt re-signing after an error
|
||||
* or do something after a successful sign
|
||||
*/
|
||||
awaitClose()
|
||||
}
|
||||
|
||||
sealed interface Action {
|
||||
data object Cancel : Action
|
||||
data object Sign : Action
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,31 @@
|
|||
package com.tangem.data.walletconnect.utils
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.currency.getNetwork
|
||||
import com.tangem.data.walletconnect.model.CAIP2
|
||||
import com.tangem.data.walletconnect.model.NamespaceKey
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
|
||||
internal interface WcNamespaceConverter {
|
||||
|
||||
val namespaceKey: NamespaceKey
|
||||
|
||||
fun toBlockchain(chainId: CAIP2): Blockchain?
|
||||
fun toCAIP2(blockchain: Blockchain): CAIP2?
|
||||
fun toBlockchain(chainId: String): Blockchain? = toCAIP2(chainId)?.let { caip2 -> toBlockchain(caip2) }
|
||||
|
||||
fun toCAIP2(network: Network): CAIP2?
|
||||
fun toCAIP2(chainId: String): CAIP2? = CAIP2.fromRaw(chainId)
|
||||
|
||||
fun toNetwork(chainId: String, wallet: UserWallet): Network?
|
||||
fun toNetwork(chainId: String, wallet: UserWallet, excludedBlockchains: ExcludedBlockchains): Network? {
|
||||
val blockchain = toBlockchain(chainId) ?: return null
|
||||
return getNetwork(
|
||||
blockchain = blockchain,
|
||||
extraDerivationPath = null,
|
||||
scanResponse = wallet.scanResponse,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package com.tangem.domain.walletconnect
|
||||
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
|
||||
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
|
||||
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
|
||||
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
|
||||
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.WcSessionApprove
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
internal class DefaultWcPairUseCaseTest {
|
||||
|
||||
private val sessionsManager: WcSessionsManager = mockk<WcSessionsManager>()
|
||||
private val associateNetworksDelegate: AssociateNetworksDelegate = mockk<AssociateNetworksDelegate>()
|
||||
private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk<CaipNamespaceDelegate>()
|
||||
private val sdkDelegate: WcPairSdkDelegate = mockk<WcPairSdkDelegate>()
|
||||
|
||||
private val url = "testUrl"
|
||||
private val source = WcPairUseCase.Source.QR
|
||||
private val loading = WcPairState.Loading
|
||||
private val sdkProposal: Wallet.Model.SessionProposal
|
||||
get() = Wallet.Model.SessionProposal(
|
||||
pairingTopic = "",
|
||||
name = "",
|
||||
description = "",
|
||||
url = "",
|
||||
icons = listOf(),
|
||||
redirect = "",
|
||||
requiredNamespaces = mapOf(),
|
||||
optionalNamespaces = mapOf(),
|
||||
properties = mapOf(),
|
||||
proposerPublicKey = "",
|
||||
relayProtocol = "",
|
||||
relayData = "",
|
||||
)
|
||||
|
||||
private val unsupportedDApp = "Apex Pro"
|
||||
private val unsupportedSdkProposal get() = sdkProposal.copy(name = unsupportedDApp)
|
||||
|
||||
private val sessionForApprove: WcSessionApprove
|
||||
get() = WcSessionApprove(
|
||||
wallet = MockUserWalletFactory.create(),
|
||||
network = listOf(),
|
||||
)
|
||||
|
||||
private val sdkApprove: Wallet.Params.SessionApprove
|
||||
get() = Wallet.Params.SessionApprove(
|
||||
proposerPublicKey = "",
|
||||
namespaces = mapOf(),
|
||||
)
|
||||
|
||||
private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result
|
||||
get() = Wallet.Model.SettledSessionResponse.Result(
|
||||
session = sdkSession,
|
||||
)
|
||||
|
||||
private val sdkSession: Wallet.Model.Session
|
||||
get() = Wallet.Model.Session(
|
||||
pairingTopic = "",
|
||||
topic = "",
|
||||
expiry = 0L,
|
||||
requiredNamespaces = mapOf(),
|
||||
optionalNamespaces = mapOf(),
|
||||
namespaces = mapOf(),
|
||||
metaData = null,
|
||||
)
|
||||
|
||||
private val Wallet.Model.Session.sessionForSave: WcSession
|
||||
get() = WcSession(
|
||||
wallet = sessionForApprove.wallet,
|
||||
sdkModel = WcSdkSessionConverter.convert(this),
|
||||
)
|
||||
|
||||
private val useCase = DefaultWcPairUseCase(
|
||||
sessionsManager = sessionsManager,
|
||||
associateNetworksDelegate = associateNetworksDelegate,
|
||||
caipNamespaceDelegate = caipNamespaceDelegate,
|
||||
sdkDelegate = sdkDelegate,
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf()
|
||||
coEvery {
|
||||
caipNamespaceDelegate.associate(
|
||||
sessionProposal = sdkProposal,
|
||||
userWallet = sessionForApprove.wallet,
|
||||
networks = sessionForApprove.network,
|
||||
)
|
||||
} returns mapOf()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pair, emmit proposal state and wait actions`() = runTest {
|
||||
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
}
|
||||
assert(awaitItem() is WcPairState.Proposal)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success pair and approve flow`() = runTest {
|
||||
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)
|
||||
val sessionForSave = sdkSession.sessionForSave
|
||||
val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right())
|
||||
|
||||
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
|
||||
coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right()
|
||||
coEvery { sessionsManager.saveSession(sessionForSave) } returns Unit
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
}
|
||||
assert(awaitItem() is WcPairState.Proposal)
|
||||
useCase.approve(sessionForApprove)
|
||||
// ignore
|
||||
useCase.reject()
|
||||
|
||||
assertEquals(approveLoading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.approve(sdkApprove)
|
||||
sessionsManager.saveSession(sessionForSave)
|
||||
}
|
||||
assertEquals(result, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success pair and reject approving`() = runTest {
|
||||
val proposerPublicKey = sdkProposal.proposerPublicKey
|
||||
|
||||
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
|
||||
coEvery { sdkDelegate.rejectSession(proposerPublicKey) } returns Unit
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
}
|
||||
assert(awaitItem() is WcPairState.Proposal)
|
||||
useCase.reject()
|
||||
coVerifyOrder {
|
||||
sdkDelegate.rejectSession(sdkProposal.proposerPublicKey)
|
||||
}
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success pair and reject unsupported dApp`() = runTest {
|
||||
coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right()
|
||||
val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp)
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
}
|
||||
assertEquals(unsupportedDAppError, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complete on pair error`() = runTest {
|
||||
val error = WcPairError.ExternalApprovalError("error")
|
||||
coEvery { sdkDelegate.pair(url) } returns error.left()
|
||||
val errorState = WcPairState.Error(error)
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
}
|
||||
assertEquals(errorState, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complete on approve error`() = runTest {
|
||||
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)
|
||||
val error = WcPairError.ExternalApprovalError("error").left()
|
||||
coEvery { sdkDelegate.pair(url) } returns sdkProposal.right()
|
||||
coEvery { sdkDelegate.approve(sdkApprove) } returns error
|
||||
|
||||
val errorResult = WcPairState.Approving.Result(sessionForApprove, error)
|
||||
|
||||
useCase.pairFlow(url, source).test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
associateNetworksDelegate.associate(sdkProposal)
|
||||
}
|
||||
assert(awaitItem() is WcPairState.Proposal)
|
||||
useCase.approve(sessionForApprove)
|
||||
|
||||
assertEquals(approveLoading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.approve(sdkApprove)
|
||||
}
|
||||
|
||||
assertEquals(errorResult, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package com.tangem.domain.walletconnect
|
||||
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.data.walletconnect.sign.FinalActionCollector
|
||||
import com.tangem.data.walletconnect.sign.MiddleActionCollector
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
|
||||
import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
internal class WcSignUseCaseDelegateTest {
|
||||
|
||||
private val middleActionCollector = mockk<MiddleActionCollector<TestMiddleAction, TestSignModel>>()
|
||||
private val finalActionCollector = mockk<FinalActionCollector<TestSignModel>>()
|
||||
private val useCase = WcSignUseCaseDelegate(
|
||||
finalActionCollector = finalActionCollector,
|
||||
middleActionCollector = middleActionCollector,
|
||||
)
|
||||
private val initSignModel = TestSignModel()
|
||||
|
||||
private val initState = WcSignState(initSignModel, WcSignStep.PreSign)
|
||||
private val signing = initState.toSigning()
|
||||
private val result = signing.toResult(Unit.right())
|
||||
private val testException = RuntimeException("test")
|
||||
|
||||
private val successSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
|
||||
currentState: WcSignState<TestSignModel>,
|
||||
) -> Unit = { state ->
|
||||
delay(2)
|
||||
emit(state.toResult(Unit.right()))
|
||||
}
|
||||
|
||||
private val failedSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
|
||||
currentState: WcSignState<TestSignModel>,
|
||||
) -> Unit
|
||||
get() = { state ->
|
||||
delay(2)
|
||||
emit(state.toResult(testException.left()))
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { middleActionCollector.onMiddleAction } returns { _, _ -> }
|
||||
every { finalActionCollector.onSign } returns { }
|
||||
every { finalActionCollector.onCancel } returns { }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke and keep flow running`() = runTest {
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success sign, keep flow running`() = runTest {
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
|
||||
useCase.invoke(initModel = initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(result, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed sign, keep flow running`() = runTest {
|
||||
val failedResult = signing.toResult(testException.left())
|
||||
every { finalActionCollector.onSign } returns failedSign
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(failedResult, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed sign and catch unknown exception`() = runTest {
|
||||
val exception = RuntimeException("asd")
|
||||
val expectedErrorState = signing.toResult(exception.left())
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(2)
|
||||
throw exception
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(expectedErrorState, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interrupt signing and complete flow on cancel call`() = runTest {
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(5)
|
||||
emit(result)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
delay(2)
|
||||
useCase.cancel()
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore multi time sign call till signed`() = runTest {
|
||||
var count = 0
|
||||
val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing)
|
||||
val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing)
|
||||
val expectedSignResult = result
|
||||
|
||||
every { finalActionCollector.onSign } returns {
|
||||
// should emit single time in this test
|
||||
emit(if (count % 2 == 0) startLoading else startLoading2)
|
||||
count = count.inc()
|
||||
delay(10)
|
||||
emit(expectedSignResult)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
useCase.sign()
|
||||
delay(2)
|
||||
assertEquals(startLoading, expectMostRecentItem())
|
||||
|
||||
// should ignore
|
||||
useCase.sign()
|
||||
delay(2)
|
||||
expectNoEvents()
|
||||
|
||||
// should ignore
|
||||
useCase.sign()
|
||||
expectNoEvents()
|
||||
|
||||
delay(8)
|
||||
assertEquals(expectedSignResult, expectMostRecentItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest {
|
||||
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
|
||||
val firstMiddleUpdate = WcSignState(
|
||||
signModel = firstTextMode,
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
val startLoading = firstMiddleUpdate.toSigning()
|
||||
val failedSign = startLoading.toResult(testException.left())
|
||||
val thirdMiddleUpdate = WcSignState(
|
||||
signModel = TestSignModel(TestMiddleAction.Three().newTestStr),
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(6)
|
||||
emit(failedSign)
|
||||
}
|
||||
|
||||
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
|
||||
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.One())
|
||||
assertEquals(firstMiddleUpdate, expectMostRecentItem())
|
||||
|
||||
useCase.sign()
|
||||
assertEquals(startLoading, awaitItem())
|
||||
|
||||
// should ignore
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.Two())
|
||||
|
||||
delay(3)
|
||||
assertEquals(failedSign, awaitItem())
|
||||
|
||||
// continue listen
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.Three())
|
||||
assertEquals(thirdMiddleUpdate, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buffered middle actions and drop on sign call`() = runTest {
|
||||
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
|
||||
val expectedFirst = initState.copy(signModel = firstTextMode)
|
||||
val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr))
|
||||
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
|
||||
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
|
||||
delay(4)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.middleAction(TestMiddleAction.One())
|
||||
useCase.middleAction(TestMiddleAction.Two())
|
||||
// must be dropped
|
||||
useCase.middleAction(TestMiddleAction.Three())
|
||||
|
||||
// 0 - 4 -> "one" is emitted
|
||||
// 4 - 8 -> "two" is emitted
|
||||
// 8 - 12 -> "Signing" is emitted, "three" ignored
|
||||
delay(2)
|
||||
assertEquals(expectedFirst, awaitItem())
|
||||
delay(4)
|
||||
assertEquals(expectedSecond, awaitItem())
|
||||
|
||||
useCase.sign()
|
||||
delay(4)
|
||||
assertEquals(expectedSecond.toSigning(), awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TestSignModel(val testStr: String = "testStr")
|
||||
|
||||
internal sealed interface TestMiddleAction {
|
||||
val newTestStr: String
|
||||
|
||||
data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction
|
||||
data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction
|
||||
data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue