diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt index a418b31a5d..42d11c055f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt @@ -3,13 +3,14 @@ package com.tangem.datasource.local.nft import androidx.datastore.core.DataStore import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection +import com.tangem.datasource.local.nft.custom.NFTPriceId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map internal class DefaultNFTPersistenceStore( private val collectionsPersistenceStore: DataStore>, - private val pricesPersistenceStore: DataStore>, + private val pricesPersistenceStore: DataStore>, ) : NFTPersistenceStore { override fun getCollections(): Flow?> = collectionsPersistenceStore.data @@ -27,11 +28,12 @@ internal class DefaultNFTPersistenceStore( } override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = pricesPersistenceStore.data - .map { it[assetId] } + .map { data -> data.associate { it.assetId to it.price }[assetId] } override suspend fun getSalePricesSync(): Map? = pricesPersistenceStore .data .firstOrNull() + ?.associate { it.assetId to it.price } override suspend fun saveCollections(collections: List) { collectionsPersistenceStore.updateData { @@ -41,7 +43,7 @@ internal class DefaultNFTPersistenceStore( override suspend fun saveSalePrice(assetId: NFTAsset.Identifier, salePrice: NFTAsset.SalePrice) { pricesPersistenceStore.updateData { - it.toMutableMap().apply { this[assetId] = salePrice } + it.toMutableList().plus(NFTPriceId(assetId = assetId, price = salePrice)) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt index cb2e6399bf..2dad3c41c8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -5,12 +5,11 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi -import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.nft.custom.NFTPriceId import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes -import com.tangem.datasource.utils.mapWithCustomKeyTypes import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -48,8 +47,8 @@ class NFTPersistenceStoreFactory @Inject constructor( // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_prices // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_prices fileName = "nft_${userWalletStringId}_${networkStringId}_prices", - types = mapWithCustomKeyTypes(), - defaultValue = emptyMap(), + types = listTypes(), + defaultValue = emptyList(), ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/custom/NFTPriceId.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/custom/NFTPriceId.kt new file mode 100644 index 0000000000..0f933cd743 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/custom/NFTPriceId.kt @@ -0,0 +1,8 @@ +package com.tangem.datasource.local.nft.custom + +import com.tangem.blockchain.nft.models.NFTAsset + +data class NFTPriceId( + val assetId: NFTAsset.Identifier, + val price: NFTAsset.SalePrice, +) \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt index 5a5eb2ecfd..1bdef12d69 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt @@ -2,8 +2,10 @@ package com.tangem.data.quotes.di import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater +import com.tangem.data.quotes.single.DefaultSingleQuoteFetcher import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.quotes.multi.MultiQuoteUpdater +import com.tangem.domain.quotes.single.SingleQuoteFetcher import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,4 +23,8 @@ internal interface QuoteFetcherModule { @Binds @Singleton fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater + + @Binds + @Singleton + fun bindSingleQuoteFetcher(impl: DefaultSingleQuoteFetcher): SingleQuoteFetcher } \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt new file mode 100644 index 0000000000..9cd56dacc2 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcher.kt @@ -0,0 +1,17 @@ +package com.tangem.data.quotes.single + +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteFetcher +import javax.inject.Inject + +internal class DefaultSingleQuoteFetcher @Inject constructor( + private val multiQuoteFetcher: MultiQuoteFetcher, +) : SingleQuoteFetcher { + + override suspend fun invoke(params: SingleQuoteFetcher.Params) = multiQuoteFetcher.invoke( + MultiQuoteFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ), + ) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt new file mode 100644 index 0000000000..fc1799ba0c --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteFetcherTest.kt @@ -0,0 +1,174 @@ +package com.tangem.data.quotes.single + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.single.SingleQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +internal class DefaultSingleQuoteFetcherTest { + + private val tangemTechApi = mockk(relaxed = true) + private val appCurrencyResponseStore = mockk(relaxed = true) + private val quotesStore = mockk(relaxed = true) + + private val multiFetcher = DefaultMultiQuoteFetcher( + tangemTechApi = tangemTechApi, + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val singleFetcher = DefaultSingleQuoteFetcher(multiFetcher) + + @Test + fun `fetch single quote successfully`() = runTest { + val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC" + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = singleFetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = setOf(params.rawCurrencyId)) + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch single quote successfully if appCurrencyId from params is not null`() = runTest { + val appCurrencyId = "usd" + val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) + + val coinIds = "BTC" + coEvery { + tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = singleFetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = setOf(currenciesId)) + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + quotesStore.storeActual(values = successResponse.quotes) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch single quote failure because appCurrencyId from params is blank`() = runTest { + val appCurrencyId = "" + val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) + + val actual = singleFetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = setOf(currenciesId)) + quotesStore.storeError(currenciesIds = setOf(currenciesId)) + } + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat() + .isEqualTo("Unable to get AppCurrency for updating quotes") + } + + @Test + fun `fetch single quote failure because api request failed`() = runTest { + val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC" + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse + coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse + + val actual = singleFetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = setOf(currenciesId)) + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + quotesStore.storeError(currenciesIds = setOf(currenciesId)) + } + + coVerify(inverse = true) { + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + @Test + fun `fetch single quote failure because app currency not found`() = runTest { + val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + + val actual = singleFetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = setOf(currenciesId)) + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = setOf(currenciesId)) + } + + coVerify(inverse = true) { + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + private companion object { + + val currenciesId = CryptoCurrency.RawID(value = "BTC") + + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + + val successResponse = QuotesResponse( + quotes = mapOf( + "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + ), + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 6ba07ef2e1..540ffdb8ed 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -190,12 +190,19 @@ internal class DefaultTransactionRepository( null } + val contractAddress = when (val identifier = nftAsset.identifier) { + is NFTAsset.Identifier.EVM -> identifier.tokenAddress + is NFTAsset.Identifier.Solana -> identifier.tokenAddress + is NFTAsset.Identifier.TON -> identifier.tokenAddress + NFTAsset.Identifier.Unknown -> "" + } + return@withContext createTransaction( amount = Amount( value = nftAsset.amount?.toBigDecimal() ?: error("Invalid amount"), token = Token( symbol = blockchain.currency, - contractAddress = "", + contractAddress = contractAddress, decimals = nftAsset.decimals ?: error("Invalid decimals"), ), ), diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt new file mode 100644 index 0000000000..6923e9a50e --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteFetcher.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.CryptoCurrency + +/** + * Fetcher of quote for [CryptoCurrency.RawID] + * +[REDACTED_AUTHOR] + */ +interface SingleQuoteFetcher : FlowFetcher { + + /** + * Params + * + * @property rawCurrencyId crypto currency id + */ + data class Params( + val rawCurrencyId: CryptoCurrency.RawID, + val appCurrencyId: String?, + ) +} \ No newline at end of file