Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-30 14:23:50 +03:00
commit c32a5fa18d
711 changed files with 18558 additions and 4312 deletions

View file

@ -0,0 +1,152 @@
package com.tangem.data.account.tokens
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensureNotNull
import arrow.core.toNonEmptyListOrNull
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import timber.log.Timber
/**
* Implementation of [MainAccountTokensMigration] for migrating tokens associated with a main account.
* The migration process involves transferring unassigned tokens from the main account to a selected account.
*
* @property accountsResponseStoreFactory Factory for creating stores to access cached account responses.
* @property userTokensSaver Saver for updating user tokens in persistent storage.
* @property walletAccountsSaver Saver for updating wallet accounts in persistent storage.
*
[REDACTED_AUTHOR]
*/
internal class DefaultMainAccountTokensMigration(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val userTokensSaver: UserTokensSaver,
private val walletAccountsSaver: WalletAccountsSaver,
) : MainAccountTokensMigration {
override suspend fun migrate(
userWalletId: UserWalletId,
derivationIndex: DerivationIndex,
): Either<Throwable, Unit> = either {
if (derivationIndex == DerivationIndex.Main) {
Timber.i("Migration skipped: derivation index is Main")
return@either
}
val store = accountsResponseStoreFactory.create(userWalletId)
val response = store.getSyncOrNull()
ensureNotNull(response) {
val exception = IllegalStateException("No cached accounts response found")
Timber.e(exception)
exception
}
val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main)
val selectedAccount = findAccount(response = response, derivationIndex = derivationIndex)
val unassignedTokens = mainAccount.findUnassignedTokens(derivationIndex)
if (unassignedTokens == null) {
Timber.i("No unassigned tokens found for migration")
return@either
}
val updatedResponse = response.copy(
accounts = response.accounts.map { account ->
when (account.id) {
mainAccount.id -> {
account.copy(tokens = account.tokens.orEmpty() - unassignedTokens)
}
selectedAccount.id -> {
selectedAccount.assignTokens(userWalletId, unassignedTokens)
}
else -> account
}
},
)
walletAccountsSaver.store(userWalletId = userWalletId, response = updatedResponse)
userTokensSaver.push(
userWalletId = userWalletId,
response = updatedResponse.toUserTokensResponse(),
onFailSend = {
// TODO: save failed state to retry later
// [REDACTED_JIRA]
val exception = IllegalStateException("Failed to push updated tokens after migration")
Timber.e(exception)
raise(exception)
},
)
}
private fun Raise<Throwable>.findAccount(
response: GetWalletAccountsResponse,
derivationIndex: DerivationIndex,
): WalletAccountDTO {
val account = response.accounts.firstOrNull { it.derivationIndex == derivationIndex.value }
return ensureNotNull(account) {
val exception = IllegalStateException("No account found with derivation index: $derivationIndex")
Timber.e(exception)
exception
}
}
private fun WalletAccountDTO.findUnassignedTokens(
derivationIndex: DerivationIndex,
): List<UserTokensResponse.Token>? {
val tokens = this.tokens
if (tokens.isNullOrEmpty()) return tokens
return tokens
.filterByDerivationIndex(derivationIndex)
.map { it.copy(accountId = null) }
.toNonEmptyListOrNull()
}
private fun List<UserTokensResponse.Token>.filterByDerivationIndex(
derivationIndex: DerivationIndex,
): List<UserTokensResponse.Token> {
return filter { savedToken ->
val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId)
if (blockchain == null) {
Timber.e("Token has unknown networkId: $savedToken")
return@filter false
}
val derivationPathValue = savedToken.derivationPath
if (derivationPathValue == null) {
Timber.e("Token has no derivation path: $savedToken")
return@filter false
}
val accountNodeRecognizer = AccountNodeRecognizer(blockchain)
val accountNodeValue = accountNodeRecognizer.recognize(derivationPathValue)
if (accountNodeValue == null) {
Timber.e("Token has unrecognized derivation path: $savedToken")
return@filter false
}
accountNodeValue == derivationIndex.value.toLong()
}
}
}

View file

@ -54,4 +54,14 @@ internal fun List<WalletAccountDTO>.assignTokens(
tokens = enrichedTokens[accountDTO.id].orEmpty(),
)
}
}
internal fun WalletAccountDTO.assignTokens(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): WalletAccountDTO {
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.filter { it.accountId == this.id }
return copy(tokens = enrichedTokens)
}

View file

@ -0,0 +1,234 @@
package com.tangem.data.account.token
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultMainAccountTokensMigrationTest {
private val accountsResponseStoreFactory = mockk<AccountsResponseStoreFactory>()
private val accountsResponseStore = mockk<AccountsResponseStore>()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val userTokensSaver = mockk<UserTokensSaver>(relaxed = true)
private val walletAccountsSaver = mockk<WalletAccountsSaver>(relaxed = true)
private val migration = DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
userTokensSaver = userTokensSaver,
walletAccountsSaver = walletAccountsSaver,
)
private val userWalletId = UserWalletId("011")
private val derivationIndex = DerivationIndex(1).getOrNull()!!
@BeforeEach
fun setupAll() {
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
}
@AfterEach
fun tearDown() {
clearMocks(accountsResponseStoreFactory, accountsResponseStore, userTokensSaver, walletAccountsSaver)
accountsResponseStoreFlow.value = null
}
@Test
fun `migrate skips when derivation index is Main`() = runTest {
// Act
val actual = migration.migrate(userWalletId, DerivationIndex.Main)
// Assert
assertEitherRight(actual)
coVerify(inverse = true) {
accountsResponseStoreFactory.create(any())
accountsResponseStore.data
walletAccountsSaver.store(userWalletId = any(), response = any())
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate fails when no cached accounts response`() = runTest {
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
val expected = IllegalStateException("No cached accounts response found")
assertEitherLeft(actual, expected)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
walletAccountsSaver.store(userWalletId = any(), response = any())
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate fails when selected account DTO not found`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(
userWalletId = userWalletId,
tokens = listOf(
createBitcoin(accountIndex = DerivationIndex.Main.value),
),
)
accountsResponseStoreFlow.value = response
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
val expected = IllegalStateException("No account found with derivation index: $derivationIndex")
assertEitherLeft(actual, expected)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
walletAccountsSaver.store(userWalletId = any(), response = any())
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate skips when no unassigned tokens`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(
userWalletId = userWalletId,
tokens = listOf(
createBitcoin(accountIndex = DerivationIndex.Main.value),
),
)
val selectedAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value,
derivationIndex = derivationIndex.value,
tokens = emptyList(),
)
accountsResponseStoreFlow.value = response.copy(accounts = response.accounts + selectedAccount)
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
assertEitherRight(actual)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
walletAccountsSaver.store(userWalletId = any(), response = any())
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate updates tokens for selected account`() = runTest {
// Arrange
val unassignedToken = createBitcoin(accountIndex = 1)
val mainAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value,
derivationIndex = DerivationIndex.Main.value,
tokens = listOf(
createBitcoin(accountIndex = 0),
unassignedToken,
),
)
val selectedAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value,
derivationIndex = derivationIndex.value,
tokens = emptyList(),
)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
),
accounts = listOf(mainAccount, selectedAccount),
unassignedTokens = emptyList(),
)
accountsResponseStoreFlow.value = response
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
assertEitherRight(actual)
val migratedResponse = response.copy(
accounts = listOf(
mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken),
selectedAccount.copy(tokens = listOf(unassignedToken)),
),
)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
walletAccountsSaver.store(userWalletId = userWalletId, response = migratedResponse)
userTokensSaver.push(
userWalletId = userWalletId,
response = migratedResponse.toUserTokensResponse(),
onFailSend = any(),
)
}
}
private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token {
return UserTokensResponse.Token(
id = "ne",
accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(accountIndex).getOrNull()!!,
).value,
networkId = "bitcoin",
derivationPath = "m/44'/60'/$accountIndex'/0/0",
name = "Phil Hinton",
symbol = "graeci",
decimals = 6487,
contractAddress = "vim",
addresses = listOf(),
)
}
}

View file

@ -12,6 +12,7 @@ android {
dependencies {
/* Project - Domain */
implementation(projects.data.common)
implementation(projects.domain.models)
implementation(projects.domain.blockaid)
implementation(projects.domain.blockaid.models)
@ -20,6 +21,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
/* DI */
implementation(deps.hilt.core)

View file

@ -0,0 +1,31 @@
package com.tangem.data.blockaid
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.common.blockaid.models.request.BlockAidScanOptions
import com.tangem.datasource.api.common.blockaid.models.request.Data
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionBulkScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
internal class BlockAidEvmScanTransactionConverter(
private val blockchain: Blockchain,
) : Converter<List<SDKTransactionData.Uncompiled>, EvmTransactionBulkScanRequest> {
override fun convert(value: List<SDKTransactionData.Uncompiled>): EvmTransactionBulkScanRequest {
return EvmTransactionBulkScanRequest(
chain = blockchain.getChainId().toString(),
options = listOf(BlockAidScanOptions.GasEstimation.value),
metadata = TransactionMetadata(domain = "https://tangem.com"),
data = value.map {
Data(
from = it.sourceAddress,
to = it.destinationAddress,
data = (it.extras as? EthereumTransactionExtras)?.callData?.dataHex.orEmpty(),
)
},
aggregated = false,
)
}
}

View file

@ -3,11 +3,19 @@ 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.GasEstimationResult
import com.domain.blockaid.models.transaction.TransactionData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
interface BlockAidRepository {
suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult
suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult
suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.blockaid
import arrow.core.Either
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
class DefaultBlockAidGasEstimate @Inject constructor(
private val repository: BlockAidRepository,
) : BlockAidGasEstimate {
override suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): Either<Throwable, GasEstimationResult> = Either.catch {
repository.getGasEstimation(cryptoCurrency = cryptoCurrency, transactionDataList = transactionDataList)
}
}

View file

@ -3,12 +3,18 @@ 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.GasEstimationResult
import com.domain.blockaid.models.transaction.TransactionData
import com.domain.blockaid.models.transaction.TransactionParams
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.blockaid.converters.GasEstimationResponseConverter
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
internal class DefaultBlockAidRepository(
private val api: BlockAidApi,
@ -30,6 +36,17 @@ internal class DefaultBlockAidRepository(
}
}
override suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult {
val blockchain = cryptoCurrency.network.toBlockchain()
return when {
blockchain.isEvm() -> scanEvmTransactionBulk(blockchain, transactionDataList)
else -> error("Gas estimation with BlockAid not supported by ${blockchain.fullName}")
}
}
private suspend fun scanEvmTransaction(data: TransactionData): CheckTransactionResult =
withContext(dispatchers.io) {
val response = api.scanJsonRpc(mapper.mapToEvmRequest(data))
@ -41,4 +58,15 @@ internal class DefaultBlockAidRepository(
val response = api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
mapper.mapToDomain(response)
}
private suspend fun scanEvmTransactionBulk(
blockchain: Blockchain,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult = withContext(dispatchers.io) {
val response = api.scanEvmTransactionBulk(
BlockAidEvmScanTransactionConverter(blockchain).convert(transactionDataList),
)
GasEstimationResponseConverter.convert(response)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.data.blockaid.converters
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.tangem.blockchain.extensions.hexToBigInteger
import com.tangem.datasource.api.common.blockaid.models.response.GasEstimationResponse
import com.tangem.utils.converter.Converter
internal object GasEstimationResponseConverter : Converter<List<GasEstimationResponse>, GasEstimationResult> {
override fun convert(value: List<GasEstimationResponse>): GasEstimationResult {
return GasEstimationResult(
estimatedGasList = value.map { it.gasEstimation.estimate.hexToBigInteger() },
)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.data.blockaid.di
import com.tangem.data.blockaid.DefaultBlockAidGasEstimate
import com.tangem.data.blockaid.DefaultBlockAidVerifier
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.blockaid.BlockAidVerifier
import dagger.Binds
import dagger.Module
@ -15,4 +17,8 @@ interface BlockAidDataModule {
@Binds
@Singleton
fun bindVerifier(verifier: DefaultBlockAidVerifier): BlockAidVerifier
@Binds
@Singleton
fun bindBlockAidGasEstimate(gasEstimate: DefaultBlockAidGasEstimate): BlockAidGasEstimate
}

View file

@ -29,6 +29,5 @@ interface ETagsStore {
enum class Key {
WalletAccounts,
UserTokens,
;
}
}

View file

@ -47,7 +47,6 @@ interface QuotesFetcher {
value = setOf(PRICE, PRICE_CHANGE_24H, PRICE_CHANGE_1W, PRICE_CHANGE_30D).combine(),
),
LAST_UPDATED_AT(value = "lastUpdatedAt"),
;
}
sealed interface Error {

View file

@ -7,7 +7,7 @@ import com.tangem.data.feedback.converters.BlockchainInfoConverter
import com.tangem.data.feedback.converters.WalletMetaInfoConverter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.models.*
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.models.scan.ScanResponse

View file

@ -6,7 +6,7 @@ import com.tangem.data.feedback.DefaultFeedbackFeatureToggles
import com.tangem.data.feedback.DefaultFeedbackRepository
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager

View file

@ -2,11 +2,13 @@ package com.tangem.data.notifications
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.notifications.repository.NotificationsRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class DefaultNotificationsRepository @Inject constructor(
@ -17,6 +19,10 @@ class DefaultNotificationsRepository @Inject constructor(
return appPreferencesStore.getSyncOrDefault(PreferencesKeys.getShouldShowNotificationKey(key), true)
}
override fun getShouldShowNotification(key: String): Flow<Boolean> {
return appPreferencesStore.get(PreferencesKeys.getShouldShowNotificationKey(key), true)
}
override suspend fun setShouldShowNotifications(key: String, value: Boolean) {
appPreferencesStore.store(PreferencesKeys.getShouldShowNotificationKey(key), value)
}

View file

@ -1,6 +1,8 @@
package com.tangem.data.swap
import arrow.core.right
import arrow.core.none
import arrow.core.some
import arrow.core.toOption
import com.squareup.moshi.Moshi
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.swap.converter.SwapDataConverter
@ -28,7 +30,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.*
import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@ -49,7 +51,6 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
private val dataSignatureVerifier: DataSignatureVerifier,
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
private val currencyStatusProxyCreator: CurrencyStatusProxyCreator,
@NetworkMoshi moshi: Moshi,
) : SwapRepositoryV2 {
@ -95,7 +96,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
val mappedProviders = pair.providers.mapNotNull {
mappedProviders[it.providerId]
}
}.filterYieldSupplyProvider(statusFrom)
if (statusFrom != null && statusTo != null && mappedProviders.isNotEmpty()) {
SwapPairModel(
@ -152,7 +153,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
val mappedProvider = pair.providers.mapNotNull {
mappedProviders[it.providerId]
}
}.filterYieldSupplyProvider(currencyStatusFrom)
if (currencyStatusFrom != null && currencyStatusTo != null && mappedProvider.isNotEmpty()) {
SwapPairModel(
@ -391,17 +392,19 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
)
}
return currencyStatusProxyCreator.createCurrencyStatus(
val quoteStatus = quote ?: singleQuoteStatusSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId),
)
return CryptoCurrencyStatusFactory.create(
currency = cryptoCurrency,
maybeQuoteStatus = quote?.right() ?: singleQuoteStatusSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId),
).right(),
maybeNetworkStatus = NetworkStatus(
network = cryptoCurrency.network,
value = NetworkStatus.MissedDerivation, // Caution!!! Do not change this status
).right(),
maybeYieldBalance = null,
).getOrNull()
).some(),
maybeQuoteStatus = quoteStatus.toOption(),
maybeYieldBalance = none(),
)
}
private fun parseTxDetails(txDetailsJson: String): TxDetails? {
@ -419,4 +422,15 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
is CryptoCurrency.Coin -> "0"
}
}
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) =
filter { provider ->
// !!!WARNING!!! Filter out dex provider if yield supply is active
val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus
if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) {
provider.type == ExpressProviderType.CEX
} else {
true
}
}
}

View file

@ -17,7 +17,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -59,7 +58,6 @@ internal object SwapDataModule {
moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
currencyStatusProxyCreator = CurrencyStatusProxyCreator(),
)
}

View file

@ -24,17 +24,18 @@ dependencies {
// endregion
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.card)
implementation(projects.domain.core)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager)
implementation(projects.domain.card)
implementation(projects.domain.models)
implementation(projects.domain.staking)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets.models)
// endregion

View file

@ -0,0 +1,35 @@
package com.tangem.data.tokens
import arrow.core.Either
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Implementation of [MultiWalletCryptoCurrenciesFetcher] that fetches crypto currencies of all accounts
*
* @property userWalletsStore [UserWallet]'s store
* @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class AccountListCryptoCurrenciesFetcher(
private val userWalletsStore: UserWalletsStore,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher {
override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
}
}

View file

@ -1,13 +1,16 @@
package com.tangem.data.tokens.di
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesFetcher
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,28 +27,38 @@ internal class MultiWalletCryptoCurrenciesFetcherModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesFetcher(
accountsFeatureToggles: AccountsFeatureToggles,
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
userTokensResponseStore: UserTokensResponseStore,
userTokensSaver: UserTokensSaver,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
expressServiceLoader: ExpressServiceLoader,
walletAccountsFetcher: WalletAccountsFetcher,
dispatchers: CoroutineDispatcherProvider,
): MultiWalletCryptoCurrenciesFetcher {
return DefaultMultiWalletCryptoCurrenciesFetcher(
demoConfig = DemoConfig(),
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
customTokensMerger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
return if (accountsFeatureToggles.isFeatureEnabled) {
AccountListCryptoCurrenciesFetcher(
userWalletsStore = userWalletsStore,
walletAccountsFetcher = walletAccountsFetcher,
dispatchers = dispatchers,
),
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
dispatchers = dispatchers,
)
)
} else {
DefaultMultiWalletCryptoCurrenciesFetcher(
demoConfig = DemoConfig(),
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
customTokensMerger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
dispatchers = dispatchers,
),
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
dispatchers = dispatchers,
)
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckRepository
import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository
import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -19,6 +20,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -96,4 +98,16 @@ internal object TokensDataModule {
tokenReceiveWarningActionStore = tokenReceiveWarningActionStore,
)
}
@Provides
@Singleton
fun provideDefaultYieldSupplyWarningsViewedRepository(
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): YieldSupplyWarningsViewedRepository {
return DefaultYieldSupplyWarningsViewedRepository(
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.tokens.repository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSet
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
internal class DefaultYieldSupplyWarningsViewedRepository(
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldSupplyWarningsViewedRepository {
override suspend fun getViewedWarnings(): Set<String> = withContext(dispatchers.io) {
appPreferencesStore.getObjectSet<String>(PreferencesKeys.YIELD_SUPPLY_WARNINGS_STATES_KEY).firstOrNull()
?: emptySet()
}
override suspend fun view(symbol: String) = withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
val stored = mutablePreferences.getObjectSet<String>(
PreferencesKeys.YIELD_SUPPLY_WARNINGS_STATES_KEY,
) ?: mutableSetOf()
val updated = stored + symbol
mutablePreferences.setObjectSet<String>(
key = PreferencesKeys.YIELD_SUPPLY_WARNINGS_STATES_KEY,
value = updated,
)
}
return@withContext
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.data.tokens
import arrow.core.left
import com.tangem.common.test.utils.assertEither
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AccountListCryptoCurrenciesFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val fetcher = AccountListCryptoCurrenciesFetcher(
userWalletsStore = userWalletsStore,
walletAccountsFetcher = walletAccountsFetcher,
dispatchers = dispatchers,
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, walletAccountsFetcher)
}
@Test
fun `returns failure if wallet is not multi-currency`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns false }
every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet
// Act
val actual = fetcher(params)
// Assert
val expected = IllegalStateException(
"${AccountListCryptoCurrenciesFetcher::class.simpleName} supports only multi-currency wallet",
).left()
assertEither(actual, expected)
verify { userWalletsStore.getSyncStrict(key = params.userWalletId) }
coVerify(inverse = true) { walletAccountsFetcher.fetch(any()) }
}
@Test
fun `returns accounts if wallet is multi-currency`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns true }
every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet
// Act
val actual = fetcher(params)
// Assert
assertEitherRight(actual)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.getSyncStrict(key = params.userWalletId)
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
}
}
@Test
fun `returns error if walletAccountsFetcher returns error`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns true }
val error = RuntimeException("fetch error")
every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet
coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } throws error
// Act
val actual = fetcher(params)
// Assert
val expected = error.left()
assertEither(actual, expected)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.getSyncStrict(key = params.userWalletId)
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
}
}
private companion object {
val userWalletId = UserWalletId("012")
}
}

View file

@ -1,7 +1,13 @@
package com.tangem.data.transaction
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.eip1559.isSupportEIP1559
import com.tangem.blockchain.blockchains.ethereum.network.EthereumFeeHistory
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchainsdk.utils.toBlockchain
@ -12,6 +18,8 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
import java.math.BigInteger
internal class DefaultFeeRepository(
private val walletManagersFacade: WalletManagersFacade,
@ -22,6 +30,53 @@ internal class DefaultFeeRepository(
return networkId.toBlockchain().isFeeApproximate(amountType)
}
override suspend fun getEthereumFeeWithoutGas(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): Fee.Ethereum {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
)
val blockchain = cryptoCurrency.network.toBlockchain()
val ethereumWalletManager = walletManager as? EthereumWalletManager
?: error("Not supported for ${cryptoCurrency.network}")
val fee = if (blockchain.isSupportEIP1559) {
val gasHistory = when (val gasHistory = ethereumWalletManager.getGasHistory()) {
is Result.Failure -> throw gasHistory.error
is Result.Success -> gasHistory.data
}
val marketPriorityFee = when (gasHistory) {
is EthereumFeeHistory.Common -> gasHistory.marketPriorityFee
is EthereumFeeHistory.Fallback -> gasHistory.gasPrice.toBigDecimal() * MULTIPLIER_GAS_PRICE_NORMAL_FEE
}
val maxFeePerGas = gasHistory.baseFee * MULTIPLIER_GAS_PRICE_NORMAL_FEE + marketPriorityFee
getEthEip1559Fee(
maxFeePerGas = maxFeePerGas.toBigInteger(),
priorityFee = marketPriorityFee.toBigInteger(),
blockchain = blockchain,
)
} else {
val gasPrice = when (val gasPrice = ethereumWalletManager.getGasPrice()) {
is Result.Failure -> throw gasPrice.error
is Result.Success -> gasPrice.data
}
getEthLegacyFee(
gasPrice = gasPrice,
blockchain = blockchain,
)
}
return fee
}
override suspend fun calculateFee(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
@ -54,4 +109,37 @@ internal class DefaultFeeRepository(
?: error("WalletManager is null"),
)
}
private fun getEthLegacyFee(gasPrice: BigInteger, blockchain: Blockchain): Fee.Ethereum.Legacy {
val amount = Amount(
value = BigDecimal.ZERO,
blockchain = blockchain,
)
return Fee.Ethereum.Legacy(
amount = amount,
gasLimit = BigInteger.ZERO,
gasPrice = gasPrice,
)
}
private fun getEthEip1559Fee(
maxFeePerGas: BigInteger,
priorityFee: BigInteger,
blockchain: Blockchain,
): Fee.Ethereum.EIP1559 {
val amount = Amount(
value = BigDecimal.ZERO,
blockchain = blockchain,
)
return Fee.Ethereum.EIP1559(
amount = amount,
gasLimit = BigInteger.ZERO,
maxFeePerGas = maxFeePerGas,
priorityFee = priorityFee,
)
}
private companion object {
val MULTIPLIER_GAS_PRICE_NORMAL_FEE = "1.2".toBigDecimal() // 120%
}
}

View file

@ -57,6 +57,17 @@ internal class DefaultTransactionRepository(
val extras = txExtras ?: getMemoExtras(networkId = network.rawId, memo)
val destination = if (amount.type is AmountType.TokenYieldSupply) {
walletManager.getYieldModuleAddress()
} else {
destination
}
val amount = if (amount.type is AmountType.TokenYieldSupply) {
amount.copy(value = BigDecimal.ZERO)
} else {
amount
}
return@withContext if (fee != null) {
walletManager.createTransaction(
amount = amount,
@ -86,11 +97,6 @@ internal class DefaultTransactionRepository(
nonce: BigInteger?,
): TransactionData.Uncompiled = withContext(dispatchers.io) {
val blockchain = network.toBlockchain()
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found")
val callData = SmartContractCallDataProviderFactory.getTokenTransferCallData(
destinationAddress = destination,
@ -98,7 +104,7 @@ internal class DefaultTransactionRepository(
blockchain = blockchain,
)
val extras = if (amount.type is AmountType.Token && callData != null) {
val extras = if (callData != null) {
createTransactionDataExtras(
callData = callData,
network = network,
@ -109,25 +115,15 @@ internal class DefaultTransactionRepository(
null
}
return@withContext if (fee != null) {
createTransaction(
amount = amount,
fee = fee,
memo = null,
destination = destination,
userWalletId = userWalletId,
network = network,
txExtras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras,
)
} else {
TransactionData.Uncompiled(
amount = amount,
sourceAddress = walletManager.wallet.address,
destinationAddress = destination,
extras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras,
fee = null,
)
}
createTransaction(
amount = amount,
fee = fee,
memo = null,
destination = destination,
userWalletId = userWalletId,
network = network,
txExtras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras,
)
}
override suspend fun createApprovalTransaction(

View file

@ -28,6 +28,10 @@ dependencies {
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.networks)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)
/** Project - Utils */
implementation(projects.core.utils)

View file

@ -1,11 +1,16 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -21,4 +26,24 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository
@Binds
@Singleton
fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository
companion object {
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(
repository: OnboardingRepository,
): TangemPayMainScreenCustomerInfoUseCase {
return TangemPayMainScreenCustomerInfoUseCase(repository = repository)
}
@Provides
@Singleton
fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase {
return TangemPayIssueOrderUseCase(repository = repository)
}
}
}

View file

@ -1,24 +1,26 @@
package com.tangem.data.pay.repository
import com.tangem.datasource.api.common.response.getOrThrow
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val TAG = "TangemPay: KycRepository"
internal class DefaultKycRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val dispatchers: CoroutineDispatcherProvider,
private val requestHelper: TangemPayRequestPerformer,
) : KycRepository {
override suspend fun getKycStartInfo() = withContext(dispatchers.io) {
requestHelper.request { authHeader ->
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
}.map {
KycStartInfo(token = it.token, locale = it.locale)
override suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getKycAccess(authHeader = authHeader)
}.result
KycStartInfo(token = result.token, locale = result.locale)
}
}
}

View file

@ -1,40 +1,108 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.ProductInstance
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
private const val VALID_STATUS = "valid"
private const val TAG = "TangemPay: OnboardingRepository"
internal class DefaultOnboardingRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
) : OnboardingRepository {
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> = either {
return requestHelper.request {
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)).getOrThrow().result
?: raise(VisaApiError.UnknownWithoutCode)
}.map { result -> result.status == VALID_STATUS }
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request {
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
}.result
result?.status == VALID_STATUS
}
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> = either {
return requestHelper.request { authHeader ->
val response = tangemPayApi.getCustomerMe(authHeader).getOrThrow()
response.result ?: raise(VisaApiError.UnknownWithoutCode)
}.map { result ->
CustomerInfo(
productInstance = result.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = result.kyc?.status,
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
getCustomerInfo(result)
}
}
override suspend fun getMainScreenCustomerInfo(): Either<UniversalError, MainScreenCustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.requestWithPersistedToken { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
val orderStatus = getOrderStatus().getOrNull() ?: error("Order status is null")
MainScreenCustomerInfo(info = getCustomerInfo(result), orderStatus = orderStatus)
}
}
override suspend fun createOrder(): Either<UniversalError, Unit> {
return requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val result = requestHelper.requestWithPersistedToken { authHeader ->
tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress))
}.result ?: error("Create order result is null")
tangemPayStorage.storeOrderId(result.data.customerWalletAddress, result.id)
}
}
private fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
val card = response?.card
val balance = response?.balance
val productInstance = response?.productInstance
val cardInfo = if (productInstance != null && card != null && balance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = balance.availableBalance,
currencyCode = balance.currency,
customerWalletAddress = productInstance.cardWalletAddress,
)
} else {
null
}
return CustomerInfo(
productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = response?.kyc?.status,
cardInfo = cardInfo,
)
}
private suspend fun getOrderStatus(): Either<UniversalError, OrderStatus> {
return requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val orderId: String = tangemPayStorage.getOrderId(walletAddress)
?: return@runWithErrorLogs OrderStatus.NOT_ISSUED
val result = requestHelper.request { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.result ?: error("Order result is null")
when (result.status) {
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.pay.repository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.fetcher.CursorBatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
private const val INITIAL_CURSOR = "initial_cursor_key"
private const val TAG = "TangemPay: TangemPayTxHistoryRepository:"
internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val requestPerformer: TangemPayRequestPerformer,
private val visaApi: TangemPayApi,
private val cacheRegistry: CacheRegistry,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : TangemPayTxHistoryRepository {
override fun getTxHistoryBatchFlow(
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = createFetcher(batchSize),
).toBatchFlow()
}
private fun createFetcher(
batchSize: Int,
): BatchFetcher<TangemPayTxHistoryListConfig, List<TangemPayTxHistoryItem>> {
return CursorBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { request, _, _ ->
val items = loadItems(config = request.params, cursor = request.cursor, limit = request.limit)
BatchFetchResult.Success(
data = items,
last = items.size < request.limit,
empty = items.isEmpty(),
)
},
cursorFromItem = { item -> item.id }, // last items id becomes next cursor
)
}
private suspend fun loadItems(
config: TangemPayTxHistoryListConfig,
cursor: String?,
limit: Int,
): List<TangemPayTxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor),
skipCache = config.refresh,
block = { fetch(customerWalletAddress = config.customerWalletAddress, cursor = cursor, pageSize = limit) },
)
return txHistoryItemsStore.getSyncOrNull(
key = config.customerWalletAddress,
cursor = cursor ?: INITIAL_CURSOR,
).orEmpty()
}
private fun getCacheKey(customerWalletAddress: String, cursor: String?): String {
return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}"
}
private suspend fun fetch(customerWalletAddress: String, cursor: String?, pageSize: Int) {
requestPerformer.runWithErrorLogs(TAG) {
val result = requestPerformer.request { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
}.result
val items = TangemPayTxHistoryItemConverter.convertList(result.transactions)
txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items)
}
}
}

View file

@ -1,80 +1,110 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
/**
* For TangemPay Customer Wallet auth we are using polygon address
*/
private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token"
@Suppress("LongParameterList")
internal class TangemPayRequestPerformer @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage,
private val userWalletsRepository: UserWalletsListRepository,
private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val networksRepository: NetworksRepository,
private val authDataSource: TangemPayAuthDataSource,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
private var customerWalletAddress: String? = null
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<Either<UniversalError, VisaAuthTokens>>? = null
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> T): Either<UniversalError, T> = either {
withContext(dispatchers.io) {
performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind()
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<UniversalError, T> {
return try {
val result = requestBlock()
Either.Right(result)
} catch (exception: Exception) {
Timber.e("$tag: $exception")
Either.Left(mapError(exception))
}
}
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = ::getAccessTokens,
refreshTokens = ::refreshAuthTokens,
)
}
suspend fun <T : Any> requestWithPersistedToken(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = { getAccessTokensIfSaved() ?: error("Cannot get saved access tokens") },
refreshTokens = ::refreshAuthTokens,
)
}
private fun getWallets(): Flow<List<UserWallet>> = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.map { requireNotNull(it) }
} else {
userWalletsListManager.userWallets
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> T,
refreshTokens: (suspend () -> Either<UniversalError, VisaAuthTokens>)? = null,
): Either<UniversalError, T> = either {
runCatching {
requestBlock("Bearer ${getAccessTokens().bind().accessToken}")
}.getOrElse { error ->
when (error) {
is ApiResponseError.HttpException -> {
if (refreshTokens != null && error.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) {
refreshOrJoin(refreshTokens).bind()
performRequest(requestBlock, refreshTokens = null).bind()
} else {
raise(mapHttpError(error))
}
}
else -> raise(VisaApiError.UnknownWithoutCode)
}
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
refreshTokens: (suspend () -> VisaAuthTokens)? = null,
): T = runCatching {
requestBlock("Bearer ${getTokens().accessToken}").getOrThrow()
}.getOrElse { error ->
val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED
if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) {
refreshOrJoin(refreshTokens)
performRequest(requestBlock, refreshTokens = null, getTokens = getTokens)
} else {
throw error
}
}
private suspend fun refreshOrJoin(
refreshTokens: suspend () -> Either<UniversalError, VisaAuthTokens>,
): Either<UniversalError, VisaAuthTokens> {
val jobToAwait: Deferred<Either<UniversalError, VisaAuthTokens>> =
private suspend fun refreshOrJoin(refreshTokens: suspend () -> VisaAuthTokens): VisaAuthTokens {
val jobToAwait: Deferred<VisaAuthTokens> =
refreshTokensMutex.withLock {
val current = refreshTokensJob
if (current == null || current.isCompleted) {
@ -87,8 +117,6 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
val result = try {
jobToAwait.await()
} catch (ignore: Throwable) {
Either.Left(VisaApiError.UnknownWithoutCode)
} finally {
refreshTokensMutex.withLock {
if (refreshTokensJob === jobToAwait && jobToAwait.isCompleted) {
@ -99,57 +127,67 @@ internal class TangemPayRequestPerformer @Inject constructor(
return result
}
private suspend fun getCustomerWalletAddress(): Either<UniversalError, String> = either {
customerWalletAddress
?: tangemPayStorage.getCustomerWalletAddress()
?: fetchAuthInputData().bind().address
suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address
private suspend fun getAccessTokens(): VisaAuthTokens {
return getAccessTokensIfSaved() ?: fetchTokens()
}
private suspend fun getAccessTokens(): Either<UniversalError, VisaAuthTokens> = either {
val address = getCustomerWalletAddress().bind()
tangemPayStorage.getAuthTokens(address) ?: fetchTokens().bind()
private suspend fun getAccessTokensIfSaved(): VisaAuthTokens? {
return tangemPayStorage.getAuthTokens(getCustomerWalletAddress())
}
private fun mapHttpError(throwable: ApiResponseError.HttpException): UniversalError {
val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
}.getOrElse {
VisaApiError.UnknownWithoutCode
}
}
private suspend fun fetchAuthInputData(): AuthInputData {
val userWallets = getWallets()
.filter { it.isNotEmpty() }
.first()
val wallet = userWallets.find { it is UserWallet.Cold } as? UserWallet.Cold
?: error("Cannot find cold user wallet")
private suspend fun fetchAuthInputData(): Either<UniversalError, AuthInputData> = either {
val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold
?: raise(VisaApiError.UnknownWithoutCode)
val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE))
.getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode)
val blockchain = Blockchain.Polygon
val derivationPath = getDerivationPath(blockchain, wallet)
val address = networksRepository.getNetworkAddresses(wallet.walletId, Network.RawID(blockchain.id))
.find { it.cryptoCurrency.network.derivationPath.value == derivationPath }?.address
?: error("Cannot get polygon address")
customerWalletAddress = address
tangemPayStorage.storeCustomerWalletAddress(address)
AuthInputData(address, wallet.cardId)
return AuthInputData(address, wallet.cardId)
}
private suspend fun fetchTokens(): Either<UniversalError, VisaAuthTokens> = either {
val inputData = fetchAuthInputData().bind()
private fun getDerivationPath(blockchain: Blockchain, wallet: UserWallet) =
blockchain.derivationPath(wallet.derivationStyleProvider.getDerivationStyle())?.rawPath
?: error("Cannot get derivation path")
private suspend fun fetchTokens(): VisaAuthTokens {
val inputData = fetchAuthInputData()
val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId)
.getOrNull()
?: return Either.Left(VisaApiError.UnknownWithoutCode)
.getOrNull() ?: error("Cannot fetch tokens")
tangemPayStorage.storeAuthTokens(inputData.address, tokens)
tokens
return tokens
}
private suspend fun refreshAuthTokens(): Either<UniversalError, VisaAuthTokens> = either {
val customerWalletAddress = getCustomerWalletAddress().bind()
val refreshToken = getAccessTokens().bind().refreshToken.value
val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull()
?: raise(VisaApiError.UnknownWithoutCode)
private suspend fun refreshAuthTokens(): VisaAuthTokens {
val customerWalletAddress = getCustomerWalletAddress()
val refreshToken = getAccessTokens().refreshToken.value
val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() ?: error("Cannot refresh tokens")
tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens)
tokens
return tokens
}
private fun mapError(throwable: Throwable): UniversalError {
return if (throwable is ApiResponseError.HttpException) {
val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
}.getOrElse {
VisaApiError.UnknownWithoutCode
}
} else {
VisaApiError.UnknownWithoutCode
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
internal object TangemPayTxHistoryItemConverter :
Converter<TangemPayTxHistoryResponse.Transaction, TangemPayTxHistoryItem> {
@Suppress("CyclomaticComplexMethod")
override fun convert(value: TangemPayTxHistoryResponse.Transaction): TangemPayTxHistoryItem {
val spend = value.spend
val collateral = value.collateral
val payment = value.payment
val fee = value.fee
return TangemPayTxHistoryItem(
id = value.id,
date = when {
spend != null -> spend.postedAt
collateral != null -> collateral.postedAt
payment != null -> payment.postedAt
fee != null -> fee.postedAt
else -> null
},
amount = when {
spend != null -> spend.amount
collateral != null -> collateral.amount
payment != null -> payment.amount
fee != null -> fee.amount
else -> null
},
merchantName = when {
spend != null -> spend.merchantName
else -> null
},
status = when {
spend != null -> spend.status
payment != null -> payment.status
else -> null
},
currency = when {
spend != null -> spend.currency
collateral != null -> collateral.currency
payment != null -> payment.currency
fee != null -> fee.currency
else -> null
},
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.utils.converter.Converter
internal object VisaTxHistoryItemConverter : Converter<VisaTxHistoryResponse.Transaction, VisaTxHistoryItem> {
override fun convert(value: VisaTxHistoryResponse.Transaction): VisaTxHistoryItem {
return VisaTxHistoryItem(
id = value.transactionId.toString(),
date = value.transactionDt,
amount = value.blockchainAmount,
fiatAmount = value.transactionAmount,
merchantName = value.merchantName,
status = value.transactionStatus,
fiatCurrency = findCurrencyByNumericCode(value.transactionCurrencyCode),
)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxHistoryItem
internal class VisaTxHistoryItemFactory {
fun create(transaction: VisaTxHistoryResponse.Transaction): VisaTxHistoryItem {
return VisaTxHistoryItem(
id = transaction.transactionId.toString(),
date = transaction.transactionDt,
amount = transaction.blockchainAmount,
fiatAmount = transaction.transactionAmount,
merchantName = transaction.merchantName,
status = transaction.transactionStatus,
fiatCurrency = findCurrencyByNumericCode(transaction.transactionCurrencyCode),
)
}
}

View file

@ -20,8 +20,6 @@ internal class VisaTxHistoryPagingSource(
val requestTxHistory: suspend (offset: Int, pageSize: Int) -> VisaTxHistoryResponse,
) : PagingSource<Int, VisaTxHistoryItem>() {
private val itemsFactory = VisaTxHistoryItemFactory()
private val cardPublicKey = params.cardPublicKey
private val pageSize = params.pageSize
private val isRefresh = params.isRefresh
@ -82,7 +80,7 @@ internal class VisaTxHistoryPagingSource(
pagedItems.update {
it.toMutableMap().apply {
this[offset] = response.transactions.map(itemsFactory::create)
this[offset] = response.transactions.map(VisaTxHistoryItemConverter::convert)
}
}
}

View file

@ -19,9 +19,11 @@ import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.blockchainsdk.models.UpdateWalletManagerResult
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.walletmanager.utils.*
import com.tangem.datasource.asset.loader.AssetLoader
@ -50,6 +52,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
import java.util.EnumSet
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@Suppress("LargeClass", "TooManyFunctions")
@ -71,7 +74,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
private val requirementsConditionConverter by lazy { SdkRequirementsConditionConverter() }
private val estimationFeeAddressFactory by lazy { EstimationFeeAddressFactory() }
private val initMutex = Mutex()
private val wmInitializationMutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun update(
userWalletId: UserWalletId,
@ -363,7 +366,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
blockchain: Blockchain,
derivationPath: String?,
): WalletManager? {
initMutex.withLock {
getWmInitializationMutex(blockchain, derivationPath).withLock {
val userWallet = getUserWallet(userWalletId)
var walletManager = walletManagersStore.getSyncOrNull(
@ -496,18 +499,26 @@ internal class DefaultWalletManagersFacade @Inject constructor(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
) ?: error("Wallet manager not found")
val destination = estimationFeeAddressFactory.makeAddress(blockchain)
val destination = when (amount.type) {
is AmountType.Token -> estimationFeeAddressFactory.makeAddress(blockchain)
is AmountType.TokenYieldSupply -> walletManager.getYieldModuleAddress()
else -> return@withContext null
}
val callData = if (amount.type is AmountType.Token) {
SmartContractCallDataProviderFactory.getTokenTransferCallData(
val callData = when (val amountType = amount.type) {
is AmountType.Token -> SmartContractCallDataProviderFactory.getTokenTransferCallData(
destinationAddress = destination,
amount = amount,
blockchain = blockchain,
)
} else {
null
is AmountType.TokenYieldSupply -> YieldSupplyContractCallDataProviderFactory.getSendCallData(
tokenContractAddress = amountType.token.contractAddress,
destinationAddress = estimationFeeAddressFactory.makeAddress(blockchain),
amount = amount,
)
else -> null
}
(walletManager as? TransactionSender)?.estimateFee(
@ -728,6 +739,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
return initializableAccountWalletManger.accountInitializationState == InitializableAccount.State.INITIALIZED
}
private fun getWmInitializationMutex(blockchain: Blockchain, derivationPath: String?): Mutex {
val key = createMutexMapKey(blockchain, derivationPath)
return wmInitializationMutexes.computeIfAbsent(key) {
Mutex()
}
}
private fun createMutexMapKey(blockchain: Blockchain, derivationPath: String?): String {
return blockchain.toNetworkId() + "|" + derivationPath
}
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {
if (tokens.isEmpty()) return

View file

@ -1,6 +1,10 @@
package com.tangem.data.walletmanager.utils
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.*
import com.tangem.blockchain.yieldsupply.providers.ethereum.factory.EthereumYieldSupplyDeployCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyEnterCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyExitCallData
import com.tangem.blockchainsdk.models.UpdateWalletManagerResult.Address
import com.tangem.domain.models.network.TxInfo
import com.tangem.utils.converter.Converter
@ -40,7 +44,18 @@ internal class TransactionDataToTxHistoryItemConverter(
TransactionStatus.Confirmed -> TxInfo.TransactionStatus.Confirmed
TransactionStatus.Unconfirmed -> TxInfo.TransactionStatus.Unconfirmed
},
type = TxInfo.TransactionType.Transfer,
type = when (val extras = value.extras) {
is EthereumTransactionExtras -> {
when (extras.callData) {
is EthereumYieldSupplyDeployCallData,
is EthereumYieldSupplyEnterCallData,
-> TxInfo.TransactionType.YieldSupply.Enter
is EthereumYieldSupplyExitCallData -> TxInfo.TransactionType.YieldSupply.Exit
else -> TxInfo.TransactionType.Transfer
}
}
else -> TxInfo.TransactionType.Transfer
},
amount = amount,
)
}

View file

@ -250,7 +250,9 @@ internal class UpdateWalletManagerResultFactoryTest {
),
),
currenciesAmounts = setOf(
UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ZERO), // default for demo
UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(
value = BigDecimal.ZERO,
), // default for demo
),
currentTransactions = emptySet(),
),
@ -272,7 +274,9 @@ internal class UpdateWalletManagerResultFactoryTest {
),
),
currenciesAmounts = setOf(
UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(value = BigDecimal.ONE), // used demo amount
UpdateWalletManagerResult.CryptoCurrencyAmount.Coin(
value = BigDecimal.ONE,
), // used demo amount
),
currentTransactions = emptySet(),
),

View file

@ -415,6 +415,6 @@ internal class DefaultWalletsRepository(
else -> ActivatePromoCodeError.ActivationFailed
}
return@fold error.left()
},)
})
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessor
@ -16,7 +16,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlin.collections.set
class DefaultHotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,

View file

@ -114,62 +114,64 @@ class DefaultWalletsRepositoryTest {
}
@Test
fun `GIVEN API returns wallets WHEN getWalletsInfo THEN should return converted wallets and update cache if requested`() = runTest {
// GIVEN
val applicationId = "test_app_id"
val wallet1Id = "1234567890abcdef"
val wallet2Id = "fedcba0987654321"
val walletResponses = listOf(
WalletResponse(
id = wallet1Id,
notifyStatus = true,
),
WalletResponse(
id = wallet2Id,
notifyStatus = false,
),
)
coEvery { tangemTechApi.getWallets(applicationId) } returns ApiResponse.Success(walletResponses)
coEvery { preferencesDataStore.updateData(any()) } returns mockk()
fun `GIVEN API returns wallets WHEN getWalletsInfo THEN should return converted wallets and update cache if requested`() =
runTest {
// GIVEN
val applicationId = "test_app_id"
val wallet1Id = "1234567890abcdef"
val wallet2Id = "fedcba0987654321"
val walletResponses = listOf(
WalletResponse(
id = wallet1Id,
notifyStatus = true,
),
WalletResponse(
id = wallet2Id,
notifyStatus = false,
),
)
coEvery { tangemTechApi.getWallets(applicationId) } returns ApiResponse.Success(walletResponses)
coEvery { preferencesDataStore.updateData(any()) } returns mockk()
// WHEN
val result = repository.getWalletsInfo(applicationId, updateCache = true)
// WHEN
val result = repository.getWalletsInfo(applicationId, updateCache = true)
// THEN
assertThat(result).hasSize(2)
assertThat(result[0].walletId.stringValue).isEqualTo(wallet1Id)
assertThat(result[0].isNotificationsEnabled).isTrue()
assertThat(result[1].walletId.stringValue).isEqualTo(wallet2Id)
assertThat(result[1].isNotificationsEnabled).isFalse()
// THEN
assertThat(result).hasSize(2)
assertThat(result[0].walletId.stringValue).isEqualTo(wallet1Id)
assertThat(result[0].isNotificationsEnabled).isTrue()
assertThat(result[1].walletId.stringValue).isEqualTo(wallet2Id)
assertThat(result[1].isNotificationsEnabled).isFalse()
coVerify(exactly = 1) { tangemTechApi.getWallets(applicationId) }
coVerify(exactly = 2) { preferencesDataStore.updateData(any()) }
}
coVerify(exactly = 1) { tangemTechApi.getWallets(applicationId) }
coVerify(exactly = 2) { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN API returns wallets WHEN getWalletsInfo with updateCache false THEN should return converted wallets without updating cache`() = runTest {
// GIVEN
val applicationId = "test_app_id"
val wallet1Id = "1234567890abcdef"
val walletResponses = listOf(
WalletResponse(
id = wallet1Id,
notifyStatus = true,
),
)
coEvery { tangemTechApi.getWallets(applicationId) } returns ApiResponse.Success(walletResponses)
fun `GIVEN API returns wallets WHEN getWalletsInfo with updateCache false THEN should return converted wallets without updating cache`() =
runTest {
// GIVEN
val applicationId = "test_app_id"
val wallet1Id = "1234567890abcdef"
val walletResponses = listOf(
WalletResponse(
id = wallet1Id,
notifyStatus = true,
),
)
coEvery { tangemTechApi.getWallets(applicationId) } returns ApiResponse.Success(walletResponses)
// WHEN
val result = repository.getWalletsInfo(applicationId, updateCache = false)
// WHEN
val result = repository.getWalletsInfo(applicationId, updateCache = false)
// THEN
assertThat(result).hasSize(1)
assertThat(result[0].walletId.stringValue).isEqualTo(wallet1Id)
assertThat(result[0].isNotificationsEnabled).isTrue()
// THEN
assertThat(result).hasSize(1)
assertThat(result[0].walletId.stringValue).isEqualTo(wallet1Id)
assertThat(result[0].isNotificationsEnabled).isTrue()
coVerify(exactly = 1) { tangemTechApi.getWallets(applicationId) }
coVerify(exactly = 0) { preferencesDataStore.updateData(any()) }
}
coVerify(exactly = 1) { tangemTechApi.getWallets(applicationId) }
coVerify(exactly = 0) { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN user wallets and application ID WHEN associateWallets THEN should convert and send to API`() = runTest {
@ -271,8 +273,8 @@ class DefaultWalletsRepositoryTest {
// GIVEN
coEvery { tangemTechApi.activatePromoCode(any()) } returns
ApiResponse.Error(
HttpException(code = HttpException.Code.NOT_FOUND, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
HttpException(code = HttpException.Code.NOT_FOUND, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
// WHEN
val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
@ -288,8 +290,8 @@ class DefaultWalletsRepositoryTest {
// GIVEN
coEvery { tangemTechApi.activatePromoCode(any()) } returns
ApiResponse.Error(
HttpException(code = HttpException.Code.CONFLICT, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
HttpException(code = HttpException.Code.CONFLICT, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
// WHEN
val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")

View file

@ -25,6 +25,7 @@ dependencies {
/** Domain */
implementation(projects.domain.yieldSupply)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.legacy)

View file

@ -0,0 +1,13 @@
package com.tangem.data.yield.supply
import com.tangem.domain.yield.supply.YieldSupplyError
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
internal data object DefaultYieldSupplyErrorResolver : YieldSupplyErrorResolver {
override fun resolve(throwable: Throwable): YieldSupplyError {
return when (throwable) {
is YieldSupplyError -> throwable
else -> YieldSupplyError.DataError(throwable)
}
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.yield.supply
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
internal class DefaultYieldSupplyMarketRepository(
private val yieldSupplyApi: YieldSupplyApi,
private val store: YieldMarketsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldSupplyMarketRepository {
override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) {
store.getSyncOrNull()
}
override suspend fun updateMarkets(): List<YieldMarketToken> = withContext(dispatchers.io) {
val response = yieldSupplyApi.getYieldMarkets().getOrThrow()
val domain = response.marketDtos.map(YieldMarketTokenConverter::convert)
store.store(domain)
domain
}
override fun getMarketsFlow(): Flow<List<YieldMarketToken>> = store.get()
}

View file

@ -17,6 +17,7 @@ import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
@ -30,6 +31,7 @@ internal class DefaultYieldSupplyTransactionRepository(
override suspend fun createEnterTransactions(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
maxNetworkFee: BigDecimal,
): List<TransactionData.Uncompiled> {
val cryptoCurrency = cryptoCurrencyStatus.currency
@ -51,27 +53,23 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency = cryptoCurrency,
) ?: error("Calculated yield contract address is null")
val yieldTokenStatus = cryptoCurrencyStatus.value.yieldSupplyStatus ?: getYieldTokenStatus(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
)
val maxNetworkFee = maxNetworkFee.convertToSdkAmount(cryptoCurrencyStatus)
return buildEnterTransactions(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
existingYieldContractAddress = existingYieldContractAddress,
cryptoCurrencyStatus = cryptoCurrencyStatus,
existingYieldAddress = existingYieldContractAddress,
calculatedYieldContractAddress = calculatedYieldContractAddress,
yieldTokenStatus = yieldTokenStatus,
maxNetworkFee = maxNetworkFee,
)
}
override suspend fun createExitTransaction(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
yieldSupplyStatus: YieldSupplyStatus,
cryptoCurrencyStatus: CryptoCurrencyStatus,
fee: Fee?,
): TransactionData.Uncompiled = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
val cryptoCurrency = cryptoCurrencyStatus.currency as CryptoCurrency.Token
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
@ -87,51 +85,80 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
callData = callData,
destinationAddress = walletManager.getYieldContract(),
yieldSupplyStatus = yieldSupplyStatus,
destinationAddress = walletManager.getYieldModuleAddress(),
amount = BigDecimal.ZERO.convertToSdkAmount(cryptoCurrencyStatus),
fee = fee,
)
}
private fun buildEnterTransactions(
override suspend fun getProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal? =
withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
walletManager.getProtocolBalance(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
}.onFailure(Timber::e).getOrThrow()
}
@Suppress("LongParameterList")
private suspend fun buildEnterTransactions(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
existingYieldContractAddress: String?,
cryptoCurrencyStatus: CryptoCurrencyStatus,
existingYieldAddress: String?,
calculatedYieldContractAddress: String,
yieldTokenStatus: YieldSupplyStatus?,
maxNetworkFee: Amount,
): MutableList<TransactionData.Uncompiled> {
val enterTransactions = mutableListOf<TransactionData.Uncompiled>()
val cryptoCurrency = cryptoCurrencyStatus.currency as CryptoCurrency.Token
val yieldSupplyStatus = getYieldTokenStatus(walletManager, cryptoCurrency)
val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus)
val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS
when {
existingYieldContractAddress == null || existingYieldContractAddress == EthereumUtils.ZERO_ADDRESS -> {
yieldSupplyStatus == null || emptyContractAddress -> {
enterTransactions.add(
createDeployTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
)
}
yieldTokenStatus == null -> error("Yield token status is null")
!yieldTokenStatus.isInitialized -> enterTransactions.add(
!yieldSupplyStatus.isInitialized -> enterTransactions.add(
createInitTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldSupplyStatus = yieldTokenStatus,
yieldContractAddress = calculatedYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
)
!yieldTokenStatus.isActive -> enterTransactions.add(
!yieldSupplyStatus.isActive -> enterTransactions.add(
createReactivateTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldSupplyStatus = yieldTokenStatus,
yieldContractAddress = calculatedYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
)
else -> Unit
}
if (yieldTokenStatus?.isAllowedToSpend == false) {
if (yieldSupplyStatus?.isAllowedToSpend != true) {
enterTransactions.add(
createTransaction(
walletManager = walletManager,
@ -141,20 +168,22 @@ internal class DefaultYieldSupplyTransactionRepository(
amount = null,
),
destinationAddress = cryptoCurrency.contractAddress,
yieldSupplyStatus = yieldTokenStatus,
amount = amount,
fee = null,
),
)
}
enterTransactions.add(
createEnterTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldSupplyStatus = yieldTokenStatus,
yieldContractAddress = calculatedYieldContractAddress,
),
)
if (cryptoCurrencyStatus.value.amount.orZero() > BigDecimal.ZERO) {
enterTransactions.add(
createEnterTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
amount = amount,
yieldContractAddress = calculatedYieldContractAddress,
),
)
}
return enterTransactions
}
@ -170,12 +199,11 @@ internal class DefaultYieldSupplyTransactionRepository(
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
walletManager.calculateYieldContract()
}.onFailure(Timber::e)
.getOrNull()
walletManager.calculateYieldModuleAddress()
}.onFailure(Timber::e).getOrThrow()
}
private suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? =
override suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? =
withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
@ -184,36 +212,42 @@ internal class DefaultYieldSupplyTransactionRepository(
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
walletManager.getYieldContract()
}.onFailure(Timber::e)
.getOrNull()
walletManager.getYieldModuleAddress()
}.onFailure(Timber::e).getOrThrow()
}
private suspend fun getYieldTokenStatus(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency,
cryptoCurrency: CryptoCurrency.Token,
): YieldSupplyStatus? = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress)
val isAllowedToSpend = walletManager.isAllowedToSpend(cryptoCurrency.contractAddress)
val isAllowedToSpend = walletManager.isAllowedToSpend(
Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
YieldSupplyStatus(
isActive = sdkSupplyStatus?.isActive == true,
isInitialized = sdkSupplyStatus?.isInitialized == true,
isAllowedToSpend = isAllowedToSpend,
)
}.onFailure(Timber::e).getOrNull()
}.onFailure(Timber::e).getOrThrow()
}
private fun createDeployTransaction(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
amount: Amount,
maxNetworkFee: Amount,
): TransactionData.Uncompiled {
val callData = YieldSupplyContractCallDataProviderFactory.getDeployCallData(
tokenContractAddress = cryptoCurrency.contractAddress,
walletAddress = walletManager.wallet.address,
maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = maxNetworkFee,
)
val factoryContractAddress = walletManager.getYieldSupplyContractAddresses()?.factoryContractAddress
@ -224,7 +258,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency = cryptoCurrency,
callData = callData,
destinationAddress = factoryContractAddress,
yieldSupplyStatus = null,
amount = amount,
fee = null,
)
}
@ -233,11 +267,12 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
yieldContractAddress: String,
yieldSupplyStatus: YieldSupplyStatus,
amount: Amount,
maxNetworkFee: Amount,
): TransactionData.Uncompiled {
val callData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData(
tokenContractAddress = cryptoCurrency.contractAddress,
maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = maxNetworkFee,
)
return createTransaction(
@ -245,7 +280,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency = cryptoCurrency,
callData = callData,
destinationAddress = yieldContractAddress,
yieldSupplyStatus = yieldSupplyStatus,
amount = amount,
fee = null,
)
}
@ -254,11 +289,12 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
yieldContractAddress: String,
yieldSupplyStatus: YieldSupplyStatus,
amount: Amount,
maxNetworkFee: Amount,
): TransactionData.Uncompiled {
val callData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData(
tokenContractAddress = cryptoCurrency.contractAddress,
maxNetworkFee = MAX_NETWORK_FEE.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = maxNetworkFee,
)
return createTransaction(
@ -266,7 +302,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency = cryptoCurrency,
callData = callData,
destinationAddress = yieldContractAddress,
yieldSupplyStatus = yieldSupplyStatus,
amount = amount,
fee = null,
)
}
@ -274,7 +310,7 @@ internal class DefaultYieldSupplyTransactionRepository(
private fun createEnterTransaction(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
yieldSupplyStatus: YieldSupplyStatus?,
amount: Amount,
yieldContractAddress: String,
): TransactionData.Uncompiled {
val callData = YieldSupplyContractCallDataProviderFactory.getEnterCallData(
@ -286,7 +322,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency = cryptoCurrency,
callData = callData,
destinationAddress = yieldContractAddress,
yieldSupplyStatus = yieldSupplyStatus,
amount = amount,
fee = null,
)
}
@ -297,7 +333,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency: CryptoCurrency,
callData: SmartContractCallData,
destinationAddress: String,
yieldSupplyStatus: YieldSupplyStatus?,
amount: Amount,
fee: Fee?,
): TransactionData.Uncompiled {
requireNotNull(cryptoCurrency as? CryptoCurrency.Token)
@ -308,8 +344,6 @@ internal class DefaultYieldSupplyTransactionRepository(
blockchain = blockchain,
)
val amount = getYieldSupplyAmount(cryptoCurrency, yieldSupplyStatus)
return if (fee != null) {
walletManager.createTransaction(
amount = amount,
@ -350,22 +384,19 @@ internal class DefaultYieldSupplyTransactionRepository(
}
}
private fun getYieldSupplyAmount(cryptoCurrency: CryptoCurrency.Token, yieldSupplyStatus: YieldSupplyStatus?) =
BigDecimal.ZERO.convertToSdkAmount(
cryptoCurrency = cryptoCurrency,
amountType = AmountType.TokenYieldSupply(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
isActive = yieldSupplyStatus?.isActive ?: false,
isInitialized = yieldSupplyStatus?.isInitialized ?: false,
isAllowedToSpend = yieldSupplyStatus?.isAllowedToSpend ?: false,
private fun getEnterAmount(cryptoCurrency: CryptoCurrency.Token, yieldSupplyStatus: YieldSupplyStatus?) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = BigDecimal.ZERO,
decimals = cryptoCurrency.decimals,
type = AmountType.TokenYieldSupply(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
private companion object {
val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO for TESTNET only
}
isActive = yieldSupplyStatus?.isActive ?: false,
isInitialized = yieldSupplyStatus?.isInitialized ?: false,
isAllowedToSpend = yieldSupplyStatus?.isAllowedToSpend ?: false,
),
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.yield.supply.converters
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.utils.converter.Converter
internal object YieldMarketTokenConverter : Converter<YieldMarketsResponse.MarketDto, YieldMarketToken> {
override fun convert(value: YieldMarketsResponse.MarketDto): YieldMarketToken {
return YieldMarketToken(
tokenAddress = value.tokenAddress,
tokenSymbol = value.tokenSymbol,
tokenName = value.tokenName,
apy = value.apy,
totalSupplied = value.totalSupplied,
totalBorrowed = value.totalBorrowed,
liquidityRate = value.liquidityRate,
borrowRate = value.borrowRate,
utilizationRate = value.utilizationRate,
isActive = value.isActive,
ltv = value.ltv,
liquidationThreshold = value.liquidationThreshold,
)
}
}

View file

@ -1,7 +1,13 @@
package com.tangem.data.yield.supply.di
import com.tangem.data.yield.supply.DefaultYieldSupplyMarketRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -25,4 +31,24 @@ internal object YieldSupplyDataModule {
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideYieldSupplyMarketRepository(
yieldSupplyApi: YieldSupplyApi,
store: YieldMarketsStore,
dispatchers: CoroutineDispatcherProvider,
): YieldSupplyMarketRepository {
return DefaultYieldSupplyMarketRepository(
yieldSupplyApi = yieldSupplyApi,
store = store,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideYieldSupplyErrorResolver(): YieldSupplyErrorResolver {
return DefaultYieldSupplyErrorResolver
}
}

View file

@ -12,11 +12,9 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYieldSupplyStatus
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
@ -26,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYieldSupplyStatus
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultYieldSupplyTransactionRepositoryTest {
@ -69,12 +68,16 @@ class DefaultYieldSupplyTransactionRepositoryTest {
@Test
fun `createEnterTransactions returns deploy-approve-enter transactions`() = runTest {
coEvery { walletManager.getYieldContract() } returns EthereumUtils.ZERO_ADDRESS
coEvery { walletManager.getYieldModuleAddress() } returns EthereumUtils.ZERO_ADDRESS
coEvery { walletManager.getYieldSupplyStatus(any()) } returns null
coEvery { walletManager.isAllowedToSpend(any()) } returns false
coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress
coEvery { walletManager.calculateYieldModuleAddress() } returns yieldContractAddress
val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus)
val result = repository.createEnterTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = BigDecimal.TEN,
)
// Assert that 3 transactions are returned: deploy, approve, enter
Truth.assertThat(result).isNotNull()
@ -84,7 +87,7 @@ class DefaultYieldSupplyTransactionRepositoryTest {
val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getDeployCallData(
walletAddress = walletManager.wallet.address,
tokenContractAddress = mockedContractAddress,
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrencyStatus),
)
val firstTransaction = result.first()
@ -115,15 +118,20 @@ class DefaultYieldSupplyTransactionRepositoryTest {
@Test
fun `createEnterTransactions returns init-approve-enter transactions`() = runTest {
coEvery { walletManager.getYieldContract() } returns yieldContractAddress
coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress
coEvery { walletManager.getYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.calculateYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.isAllowedToSpend(any()) } returns false
coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus(
isActive = false,
isInitialized = false,
maxNetworkFee = BigDecimal.TEN,
)
val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus)
val result = repository.createEnterTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = BigDecimal.TEN,
)
// Assert that 3 transactions are returned: init token, approve, enter
Truth.assertThat(result).isNotNull()
@ -132,7 +140,7 @@ class DefaultYieldSupplyTransactionRepositoryTest {
// Check transaction - init token
val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getInitTokenCallData(
tokenContractAddress = mockedContractAddress,
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrencyStatus),
)
val firstTransaction = result.first()
@ -163,15 +171,20 @@ class DefaultYieldSupplyTransactionRepositoryTest {
@Test
fun `createEnterTransactions returns reactivate-approve-enter transactions`() = runTest {
coEvery { walletManager.getYieldContract() } returns yieldContractAddress
coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress
coEvery { walletManager.getYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.calculateYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.isAllowedToSpend(any()) } returns false
coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus(
isActive = false,
isInitialized = true,
maxNetworkFee = BigDecimal.TEN,
)
val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus)
val result = repository.createEnterTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = BigDecimal.TEN,
)
// Assert that 3 transactions are returned: reactivate token, approve, enter
Truth.assertThat(result).isNotNull()
@ -180,7 +193,7 @@ class DefaultYieldSupplyTransactionRepositoryTest {
// Check transaction - reactivate token
val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData(
tokenContractAddress = mockedContractAddress,
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrencyStatus),
)
val firstTransaction = result.first()
@ -211,8 +224,8 @@ class DefaultYieldSupplyTransactionRepositoryTest {
@Test
fun `createEnterTransactions returns reactivate-enter transactions`() = runTest {
coEvery { walletManager.getYieldContract() } returns yieldContractAddress
coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress
coEvery { walletManager.getYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.calculateYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus(
isActive = false,
isInitialized = true,
@ -220,7 +233,11 @@ class DefaultYieldSupplyTransactionRepositoryTest {
)
coEvery { walletManager.isAllowedToSpend(any()) } returns true
val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus)
val result = repository.createEnterTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = BigDecimal.TEN,
)
// Assert that 2 transactions are returned: approve, enter
Truth.assertThat(result).isNotNull()
@ -229,7 +246,7 @@ class DefaultYieldSupplyTransactionRepositoryTest {
// Check transaction - reactivate token
val firstExpectedCallData = YieldSupplyContractCallDataProviderFactory.getReactivateTokenCallData(
tokenContractAddress = mockedContractAddress,
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrency),
maxNetworkFee = BigDecimal.TEN.convertToSdkAmount(cryptoCurrencyStatus),
)
val firstTransaction = result.first()
@ -248,8 +265,8 @@ class DefaultYieldSupplyTransactionRepositoryTest {
@Test
fun `createEnterTransactions returns enter transactions`() = runTest {
coEvery { walletManager.getYieldContract() } returns yieldContractAddress
coEvery { walletManager.calculateYieldContract() } returns yieldContractAddress
coEvery { walletManager.getYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.calculateYieldModuleAddress() } returns yieldContractAddress
coEvery { walletManager.getYieldSupplyStatus(any()) } returns SDKYieldSupplyStatus(
isActive = true,
isInitialized = true,
@ -257,7 +274,11 @@ class DefaultYieldSupplyTransactionRepositoryTest {
)
coEvery { walletManager.isAllowedToSpend(any()) } returns true
val result = repository.createEnterTransactions(userWalletId, cryptoCurrencyStatus)
val result = repository.createEnterTransactions(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = BigDecimal.TEN,
)
// Assert that transaction is returned: enter
Truth.assertThat(result).isNotNull()
@ -277,9 +298,7 @@ class DefaultYieldSupplyTransactionRepositoryTest {
val expectedCallData =
YieldSupplyContractCallDataProviderFactory.getExitCallData(mockedContractAddress)
val yieldSupplyStatus = mockk<YieldSupplyStatus>(relaxed = true)
val result = repository.createExitTransaction(userWalletId, cryptoCurrency, yieldSupplyStatus, null)
val result = repository.createExitTransaction(userWalletId, cryptoCurrencyStatus, null)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)