diff --git a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json index cfd76c709e..2de6110b6e 100644 --- a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json +++ b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "55f2651d215126dd0465b9c711165cba", + "identityHash": "5d37870ed1a5b37c62e9836dc3fd061c", "entities": [ { "tableName": "express_provider", @@ -510,7 +510,7 @@ "notNull": true }, { - "fieldPath": "onrampAvailable", + "fieldPath": "isOnrampAvailable", "columnName": "onramp_available", "affinity": "INTEGER", "notNull": true @@ -551,11 +551,66 @@ "code" ] } + }, + { + "tableName": "token_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`network_id` TEXT NOT NULL, `contract_address` TEXT NOT NULL, `coin_id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`network_id`, `contract_address`))", + "fields": [ + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractAddress", + "columnName": "contract_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coinId", + "columnName": "coin_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "network_id", + "contract_address" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '55f2651d215126dd0465b9c711165cba')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d37870ed1a5b37c62e9836dc3fd061c')" ] } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt index b3dccfefd5..e2405b39a2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt @@ -5,6 +5,7 @@ import androidx.room.Room import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao +import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -37,5 +38,8 @@ internal interface TxHistoryModule { @Provides fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao() + + @Provides + fun provideTokenInfoDao(database: TxHistoryDatabase): TokenInfoDao = database.tokenInfoDao() } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt index 331c2ce3e7..3993988bfe 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt @@ -4,11 +4,13 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao +import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity @Database( version = 1, @@ -18,6 +20,7 @@ import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEnti ExpressOnrampEntity::class, ExpressSyncStateEntity::class, OnrampCountryEntity::class, + TokenInfoEntity::class, ], ) abstract class TxHistoryDatabase : RoomDatabase() { @@ -25,4 +28,6 @@ abstract class TxHistoryDatabase : RoomDatabase() { abstract fun expressHistoryDao(): ExpressHistoryDao abstract fun syncStateDao(): ExpressSyncStateDao + + abstract fun tokenInfoDao(): TokenInfoDao } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/TokenInfoDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/TokenInfoDao.kt new file mode 100644 index 0000000000..6ea6e88336 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/TokenInfoDao.kt @@ -0,0 +1,33 @@ +package com.tangem.datasource.local.txhistory.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity + +@Dao +interface TokenInfoDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(items: List) + + /** + * Cached tokens for the given networks/contracts. Filters each column independently, so the result is a + * cross-product superset of the `(networkId, contractAddress)` pairs — the caller must match exact pairs. + * Contract match is case-insensitive; pass [minUpdatedAt] = `now - ttl` to drop stale rows. + */ + @Query( + """ + SELECT * FROM token_info + WHERE network_id IN (:networkIds) + AND contract_address COLLATE NOCASE IN (:contractAddresses) + AND updated_at >= :minUpdatedAt + """, + ) + suspend fun getCached( + networkIds: Collection, + contractAddresses: Collection, + minUpdatedAt: Long = 0, + ): List +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/TokenInfoEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/TokenInfoEntity.kt new file mode 100644 index 0000000000..38d12885b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/TokenInfoEntity.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.local.txhistory.db.entity.express + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * Cached token data fetched from [TangemTechApi.getCoins], keyed by network id + contract address. + */ +@Entity( + tableName = "token_info", + primaryKeys = ["network_id", "contract_address"], +) +data class TokenInfoEntity( + + @ColumnInfo(name = "network_id") + val networkId: String, + + @ColumnInfo(name = "contract_address") + val contractAddress: String, + + /** Coin id from the backend (used as the token's raw currency id and for the icon URL). */ + @ColumnInfo(name = "coin_id") + val coinId: String, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "symbol") + val symbol: String, + + @ColumnInfo(name = "decimals") + val decimals: Int, + + /** Last refresh timestamp in epoch milliseconds, used to evict stale entries. */ + @ColumnInfo(name = "updated_at") + val updatedAt: Long, +) \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt b/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt new file mode 100644 index 0000000000..98fff60938 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt @@ -0,0 +1,15 @@ +package com.tangem.data.common.txhistory + +import com.tangem.datasource.api.express.models.response.ExchangeItemResponse +import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse + +/** + * Persists express (exchange/onramp) transactions into the local tx-history database and fetches any missing token + * metadata for the referenced assets. + */ +interface ExpressHistoryRepository { + + suspend fun storeExchanges(ownerAddress: String, items: List) + + suspend fun storeOnramps(ownerAddress: String, items: List) +} \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 6d69d7ab7b..9d663279d8 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -4,6 +4,7 @@ import android.net.Uri import com.squareup.moshi.Moshi import com.tangem.blockchain.extensions.toBigDecimalOrDefault import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.data.onramp.converters.CountryConverter import com.tangem.data.onramp.converters.CurrencyConverter import com.tangem.data.onramp.converters.PaymentMethodConverter @@ -76,6 +77,7 @@ internal class DefaultOnrampRepository( private val walletManagersFacade: WalletManagersFacade, private val dataSignatureVerifier: DataSignatureVerifier, private val expressHistoryDao: ExpressHistoryDao, + private val expressHistoryRepository: ExpressHistoryRepository, private val txHistoryFeatureToggles: TxHistoryFeatureToggles, moshi: Moshi, ) : OnrampRepository { @@ -172,7 +174,7 @@ internal class DefaultOnrampRepository( .getOrThrow() if (txHistoryFeatureToggles.isNewTxHistoryEnabled) { - expressHistoryDao.upsertOnramps(listOf(response.toEntity(ownerAddress = response.payoutAddress))) + expressHistoryRepository.storeOnramps(ownerAddress = response.payoutAddress, items = listOf(response)) } statusConverter.convert(response) diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt index 0cab59284f..f994817fea 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt @@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.data.onramp.DefaultHotCryptoRepository import com.tangem.data.onramp.DefaultOnrampErrorResolver import com.tangem.data.onramp.DefaultOnrampRepository @@ -59,6 +60,7 @@ internal object OnrampDataModule { dataSignatureVerifier: DataSignatureVerifier, onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore, expressHistoryDao: ExpressHistoryDao, + expressHistoryRepository: ExpressHistoryRepository, txHistoryFeatureToggles: TxHistoryFeatureToggles, @NetworkMoshi moshi: Moshi, ): OnrampRepository { @@ -76,6 +78,7 @@ internal object OnrampDataModule { walletManagersFacade = walletManagersFacade, dataSignatureVerifier = dataSignatureVerifier, expressHistoryDao = expressHistoryDao, + expressHistoryRepository = expressHistoryRepository, txHistoryFeatureToggles = txHistoryFeatureToggles, moshi = moshi, ) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt index 31d1f0e300..7afc027d50 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt @@ -1,8 +1,10 @@ package com.tangem.data.txhistory.di +import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils +import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher @@ -32,4 +34,8 @@ internal interface TxHistoryDataModule { @Binds fun provideTxHistoryFetcherUtils(default: DefaultTxHistoryFetcherUtils): TxHistoryFetcherUtils + + @Binds + @Singleton + fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository } \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcher.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcher.kt index e832a9a104..8540b5fd7c 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcher.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcher.kt @@ -4,7 +4,7 @@ import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelS import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTriggerInstance import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes -import com.tangem.data.txhistory.repository.ExpressHistoryRepository +import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse @@ -31,7 +31,7 @@ internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor( @Assisted private val accountId: AccountId, private val utils: TxHistoryFetcherUtils, private val expressSyncStateDao: ExpressSyncStateDao, - private val expressHistoryRepository: ExpressHistoryRepository, + private val expressHistoryRepository: DefaultExpressHistoryRepository, ) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils { private val userWalletId: UserWalletId get() = accountId.userWalletId diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt similarity index 74% rename from data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt rename to data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt index cd24fe0123..197fb4eaa6 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt @@ -1,12 +1,11 @@ package com.tangem.data.txhistory.repository +import com.tangem.data.common.txhistory.ExpressHistoryRepository +import com.tangem.data.txhistory.repository.factory.TokenInfoRepository +import com.tangem.data.txhistory.repository.factory.toAssetId import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi -import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse -import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse -import com.tangem.datasource.api.express.models.response.ExchangeItemResponse -import com.tangem.datasource.api.express.models.response.ExpressPagination -import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta +import com.tangem.datasource.api.express.models.response.* import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse @@ -15,20 +14,25 @@ import com.tangem.datasource.local.converter.toEntity import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.AppCoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import javax.inject.Inject /** - * Fetches express (exchange & onramp) transaction history from the API and persists it into the local database. - * + * Fetches express (exchange & onramp) transaction history from the API, persists it into the local database, and + * fetches any missing token metadata for the referenced assets. */ -internal class ExpressHistoryRepository @Inject constructor( +internal class DefaultExpressHistoryRepository @Inject constructor( private val exchangeApi: TangemExpressApi, private val onrampApi: OnrampApi, private val expressHistoryDao: ExpressHistoryDao, private val expressSyncStateDao: ExpressSyncStateDao, -) { + private val tokenInfoRepository: TokenInfoRepository, + private val appScope: AppCoroutineScope, +) : ExpressHistoryRepository { suspend fun fetchExchangeHistory( fromAddress: String, @@ -44,7 +48,7 @@ internal class ExpressHistoryRepository @Inject constructor( limit = limit, ).getOrThrow() - saveExchanges(ownerAddress = fromAddress, items = response.items) + storeExchanges(ownerAddress = fromAddress, items = response.items) persistHistoryState( type = ExpressSyncStateEntity.Type.EXCHANGE, address = fromAddress, @@ -68,7 +72,7 @@ internal class ExpressHistoryRepository @Inject constructor( limit = limit, ).getOrThrow() - saveExchanges(ownerAddress = fromAddress, items = response.items) + storeExchanges(ownerAddress = fromAddress, items = response.items) persistDeltaState( type = ExpressSyncStateEntity.Type.EXCHANGE, address = fromAddress, @@ -91,7 +95,7 @@ internal class ExpressHistoryRepository @Inject constructor( limit = limit, ).getOrThrow() - saveOnramps(ownerAddress = payoutAddress, items = response.items) + storeOnramps(ownerAddress = payoutAddress, items = response.items) persistHistoryState( type = ExpressSyncStateEntity.Type.ONRAMP, address = payoutAddress, @@ -115,7 +119,7 @@ internal class ExpressHistoryRepository @Inject constructor( limit = limit, ).getOrThrow() - saveOnramps(ownerAddress = payoutAddress, items = response.items) + storeOnramps(ownerAddress = payoutAddress, items = response.items) persistDeltaState( type = ExpressSyncStateEntity.Type.ONRAMP, address = payoutAddress, @@ -124,16 +128,33 @@ internal class ExpressHistoryRepository @Inject constructor( return response } + override suspend fun storeExchanges(ownerAddress: String, items: List) { + if (items.isEmpty()) return + val entities = items.map { it.toEntity(ownerAddress) } + expressHistoryDao.upsertExchanges(entities) + fetchMissingTokenInfo( + buildSet { + entities.forEach { entity -> + add(entity.from.toAssetId()) + add(entity.to.toAssetId()) + } + }, + ) + } + + override suspend fun storeOnramps(ownerAddress: String, items: List) { + if (items.isEmpty()) return + val entities = items.map { it.toEntity(ownerAddress) } + expressHistoryDao.upsertOnramps(entities) + fetchMissingTokenInfo(entities.mapTo(mutableSetOf()) { it.to.toAssetId() }) + } + suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? { return expressSyncStateDao.observe(type = type.name, address = address).first() } - private suspend fun saveExchanges(ownerAddress: String, items: List) { - expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) }) - } - - private suspend fun saveOnramps(ownerAddress: String, items: List) { - expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) }) + private fun fetchMissingTokenInfo(assetIds: Set) { + appScope.launch { tokenInfoRepository.fetchMissing(assetIds) } } private suspend fun persistHistoryState( diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactory.kt index 2645b0c017..c2257c8da1 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactory.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactory.kt @@ -4,6 +4,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.express.models.ExpressAsset @@ -14,16 +15,13 @@ import kotlinx.coroutines.flow.first import javax.inject.Inject /** - * Resolves a portfolio [CryptoCurrency] for every express asset (network id + contract address) referenced by a - * batch of exchange/onramp entities. - * - * Strategy: read every account of every wallet ONCE (via [MultiAccountListSupplier]) and match each express asset - * against the flattened portfolio currencies by network id + contract address. When nothing matches — notably - * tokens that are not present in any portfolio — a coin is built for the asset's network as a fallback (for now). + * Resolves a [CryptoCurrency] for every express asset (network id + contract address) referenced by a batch of + * exchange/onramp entities. */ internal class ExpressTransactionAssetFactory @Inject constructor( private val multiAccountListSupplier: MultiAccountListSupplier, private val userWalletsListRepository: UserWalletsListRepository, + private val tokenInfoRepository: TokenInfoRepository, excludedBlockchains: ExcludedBlockchains, ) { @@ -31,7 +29,7 @@ internal class ExpressTransactionAssetFactory @Inject constructor( /** * Builds a `assetId -> resolved currency` map covering both legs of every swap and the to-leg of every onramp. - * Entries whose currency could not be resolved at all (no match and no fallback coin) are omitted. + * Entries whose currency could not be resolved at all are omitted. */ suspend fun create( userWalletId: UserWalletId, @@ -55,14 +53,52 @@ internal class ExpressTransactionAssetFactory @Inject constructor( val userWallet = userWalletsListRepository.userWalletsSync() .firstOrNull { it.walletId == userWalletId } + val cachedTokens = loadCachedTokens(assetIds) + return buildMap { assetIds.forEach { id -> - val currency = portfolioCurrencies.findMatching(id) ?: createFallbackCoin(id, userWallet) + val currency = portfolioCurrencies.findMatching(id) ?: resolveUnmatched(id, cachedTokens, userWallet) if (currency != null) put(id, currency) } } } + private fun resolveUnmatched( + id: ExpressAsset.ID, + cachedTokens: Map, + userWallet: UserWallet?, + ): CryptoCurrency? { + return if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) { + createCoin(id, userWallet) + } else { + cachedTokens[cacheKey(id)]?.let { createToken(it, userWallet) } + } + } + + private suspend fun loadCachedTokens(assetIds: Set): Map = + tokenInfoRepository.getCached(assetIds).associateBy { cacheKey(it.networkId, it.contractAddress) } + + private fun createToken(entity: TokenInfoEntity, userWallet: UserWallet?): CryptoCurrency.Token? { + userWallet ?: return null + return cryptoCurrencyFactory.createToken( + token = CryptoCurrencyFactory.Token( + name = entity.name, + symbol = entity.symbol, + contractAddress = entity.contractAddress, + decimals = entity.decimals, + id = entity.coinId, + ), + networkId = entity.networkId, + extraDerivationPath = null, + userWallet = userWallet, + ) + } + + private fun cacheKey(id: ExpressAsset.ID): String = cacheKey(id.networkId, id.contractAddress) + + private fun cacheKey(networkId: String, contractAddress: String): String = + "$networkId|${contractAddress.lowercase()}" + private fun List.findMatching(id: ExpressAsset.ID): CryptoCurrency? { val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE return firstOrNull { currency -> @@ -76,9 +112,7 @@ internal class ExpressTransactionAssetFactory @Inject constructor( } } - // TODO txHistory: tokens that are not in any portfolio cannot be resolved yet — fall back to a coin on the asset's - // network. - private fun createFallbackCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? { + private fun createCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? { userWallet ?: return null return cryptoCurrencyFactory.createCoin( networkId = id.networkId, diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepository.kt new file mode 100644 index 0000000000..02dd48a728 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepository.kt @@ -0,0 +1,134 @@ +package com.tangem.data.txhistory.repository.factory + +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** + * Owns the cached token metadata (from [TangemTechApi.getCoins]) so [ExpressTransactionAssetFactory] can resolve + * tokens that are not in any user portfolio. [fetchMissing] populates the cache; [getCached] reads it back. + */ +internal class TokenInfoRepository @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val tokenInfoDao: TokenInfoDao, + private val multiAccountListSupplier: MultiAccountListSupplier, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** Cached token infos for the token assets among [assetIds] (coins are skipped). */ + suspend fun getCached(assetIds: Set): List = withContext(dispatchers.io) { + val networkIds = mutableSetOf() + val contracts = mutableSetOf() + assetIds.forEach { id -> + if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach + networkIds += id.networkId + contracts += id.contractAddress + } + if (networkIds.isEmpty()) emptyList() else tokenInfoDao.getCached(networkIds, contracts) + } + + /** Fetches and caches metadata for token assets that are stale/missing in the cache and not already owned. */ + suspend fun fetchMissing(assetIds: Set) = withContext(dispatchers.io) { + // Coins resolve without the catalog — only tokens are looked up. + val tokenIds = mutableListOf() + val networkIds = mutableSetOf() + val contracts = mutableSetOf() + assetIds.forEach { id -> + if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach + tokenIds += id + networkIds += id.networkId + contracts += id.contractAddress + } + if (tokenIds.isEmpty()) return@withContext + + val now = System.currentTimeMillis() + val freshKeys = tokenInfoDao.getCached( + networkIds = networkIds, + contractAddresses = contracts, + minUpdatedAt = now - CACHE_TTL_MILLIS, + ).mapTo(hashSetOf()) { cacheKey(it.networkId, it.contractAddress) } + + val notCached = tokenIds.filterNot { cacheKey(it.networkId, it.contractAddress) in freshKeys } + if (notCached.isEmpty()) return@withContext + + // Tokens already present in any portfolio don't need a catalog fetch. + val portfolioKeys = portfolioTokenKeys() + val unresolved = notCached.filterNot { cacheKey(it.networkId, it.contractAddress) in portfolioKeys } + if (unresolved.isEmpty()) return@withContext + + val entities = fetchTokenInfos(unresolved, now) + if (entities.isNotEmpty()) tokenInfoDao.upsert(entities) + } + + /** Cache keys of every token owned across all wallets. */ + private suspend fun portfolioTokenKeys(): Set = buildSet { + multiAccountListSupplier.invoke().first().forEach { accountList -> + accountList.flattenCurrencies().forEach { currency -> + if (currency is CryptoCurrency.Token) { + add(cacheKey(currency.network.rawId, currency.contractAddress)) + } + } + } + } + + private suspend fun fetchTokenInfos(ids: List, timestamp: Long): List { + val requestedPairs = mutableSetOf() + val networkIds = mutableSetOf() + val contracts = mutableSetOf() + ids.forEach { id -> + requestedPairs += cacheKey(id.networkId, id.contractAddress) + networkIds += id.networkId + contracts += id.contractAddress + } + return try { + // getCoins returns a cross-product of the queried networks×contracts, so the response is filtered back + // to the exact requested pairs. + val coins = tangemTechApi.getCoins( + networkIds = networkIds.joinToString(separator = ","), + contractAddresses = contracts.joinToString(separator = ","), + active = true, + ).getOrThrow().coins + + coins.flatMap { coin -> + coin.networks.mapNotNull { network -> + val contract = network.contractAddress + val decimals = network.decimalCount?.toInt() + if (contract == null || decimals == null || + cacheKey(network.networkId, contract) !in requestedPairs + ) { + return@mapNotNull null + } + TokenInfoEntity( + networkId = network.networkId, + contractAddress = contract, + coinId = coin.id, + name = coin.name, + symbol = coin.symbol, + decimals = decimals, + updatedAt = timestamp, + ) + } + } + } catch (e: Throwable) { + TangemLogger.w("Failed to fetch token info for ${ids.size} express assets", e) + emptyList() + } + } + + private fun cacheKey(networkId: String, contractAddress: String): String = + "$networkId|${contractAddress.lowercase()}" + + private companion object { + val CACHE_TTL_MILLIS: Long = TimeUnit.DAYS.toMillis(10) + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcherTest.kt index bb2121c651..fc4cfeedd1 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultExpressTxHistoryFetcherTest.kt @@ -3,7 +3,7 @@ package com.tangem.data.txhistory.fetcher import com.google.common.truth.Truth.assertThat import com.tangem.test.core.TestAppCoroutineScope import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.data.txhistory.repository.ExpressHistoryRepository +import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse import com.tangem.datasource.api.express.models.response.ExpressPagination @@ -30,7 +30,7 @@ import org.junit.jupiter.api.TestInstance internal class DefaultExpressTxHistoryFetcherTest { private val expressSyncStateDao: ExpressSyncStateDao = mockk() - private val expressHistoryRepository: ExpressHistoryRepository = mockk() + private val expressHistoryRepository: DefaultExpressHistoryRepository = mockk() private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt similarity index 78% rename from data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt rename to data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt index 0484e956ea..2b6fa4523c 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/ExpressHistoryRepositoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt @@ -1,7 +1,7 @@ package com.tangem.data.txhistory.repository import com.google.common.truth.Truth.assertThat -import com.tangem.datasource.local.converter.toEntity +import com.tangem.data.txhistory.repository.factory.TokenInfoRepository import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.express.TangemExpressApi @@ -14,16 +14,17 @@ import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse +import com.tangem.datasource.local.converter.toEntity import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao -import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.TestAppCoroutineScope import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk -import io.mockk.slot import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -31,23 +32,26 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class ExpressHistoryRepositoryTest { +internal class DefaultExpressHistoryRepositoryTest { private val exchangeApi: TangemExpressApi = mockk() private val onrampApi: OnrampApi = mockk() private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true) private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true) + private val tokenInfoRepository: TokenInfoRepository = mockk(relaxUnitFun = true) - private val repository = ExpressHistoryRepository( + private val repository = DefaultExpressHistoryRepository( exchangeApi = exchangeApi, onrampApi = onrampApi, expressHistoryDao = expressHistoryDao, expressSyncStateDao = expressSyncStateDao, + tokenInfoRepository = tokenInfoRepository, + appScope = TestAppCoroutineScope(), ) @BeforeEach fun setup() { - clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao) + clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao, tokenInfoRepository) } // region exchange history @@ -91,24 +95,6 @@ internal class ExpressHistoryRepositoryTest { } } - @Test - fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest { - // GIVEN - val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination()) - stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) - coEvery { - exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) - } returns ApiResponse.Success(response) - - // WHEN - repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID, limit = 25) - - // THEN - coVerify(exactly = 1) { - exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25) - } - } - @Test fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest { // GIVEN @@ -172,24 +158,6 @@ internal class ExpressHistoryRepositoryTest { coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } } - @Test - fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest { - // GIVEN - val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination()) - stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null) - coEvery { - onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = any()) - } returns ApiResponse.Success(response) - - // WHEN - repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID) - - // THEN - coVerify(exactly = 1) { - onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT) - } - } - @Test fun `GIVEN sync state WHEN fetchOnrampHistoryDelta THEN passes delta cursor and persists items`() = runTest { // GIVEN @@ -230,42 +198,53 @@ internal class ExpressHistoryRepositoryTest { // endregion - // region syncState + // region store @Test - fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest { + fun `GIVEN exchanges WHEN storeExchanges THEN persists entities and fetches missing info for both legs`() = runTest { // GIVEN - val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR) - stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state) + val item = createExchangeItem() // WHEN - val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS) + repository.storeExchanges(ownerAddress = ADDRESS, items = listOf(item)) // THEN - assertThat(result).isEqualTo(state) + coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) } + coVerify(exactly = 1) { + tokenInfoRepository.fetchMissing( + setOf( + ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xfromContract"), + ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract"), + ), + ) + } } @Test - fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest { + fun `GIVEN onramps WHEN storeOnramps THEN persists entities and fetches missing info for to-leg`() = runTest { // GIVEN - val items = listOf( - createExchangeItem(txId = "tx-1"), - createExchangeItem(txId = "tx-2"), - ) - val response = ExchangeHistoryResponse(items = items, pagination = pagination()) - stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) - coEvery { - exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) - } returns ApiResponse.Success(response) - val saved = slot>() - coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit + val item = createOnrampItem() // WHEN - repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID) + repository.storeOnramps(ownerAddress = ADDRESS, items = listOf(item)) // THEN - assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) }) - assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS) + coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } + coVerify(exactly = 1) { + tokenInfoRepository.fetchMissing(setOf(ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract"))) + } + } + + @Test + fun `GIVEN empty items WHEN store THEN does nothing`() = runTest { + // WHEN + repository.storeExchanges(ownerAddress = ADDRESS, items = emptyList()) + repository.storeOnramps(ownerAddress = ADDRESS, items = emptyList()) + + // THEN + coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) } + coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) } + coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) } } // endregion @@ -313,8 +292,6 @@ internal class ExpressHistoryRepositoryTest { refundNetwork = null, refundContractAddress = null, createdAt = "2026-06-01T00:00:00Z", - // todo txHistory uncomment - // updatedAt = "2026-06-01T00:05:00Z", payTill = null, averageDuration = null, fromContractAddress = "0xfromContract", @@ -338,8 +315,6 @@ internal class ExpressHistoryRepositoryTest { externalTxUrl = null, payoutHash = "payout-hash", createdAt = "2026-06-01T00:00:00Z", - // todo txHistory uncomment - // updatedAt = "2026-06-01T00:05:00Z", fromCurrencyCode = "USD", fromAmount = "100.0", fromPrecision = 2, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt new file mode 100644 index 0000000000..6cd6041c66 --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt @@ -0,0 +1,130 @@ +package com.tangem.data.txhistory.repository.factory + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.currency.CryptoCurrency +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +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 ExpressTransactionAssetFactoryTest { + + private val tokenInfoRepository: TokenInfoRepository = mockk() + private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val userWallet = MockUserWalletFactory.create() + + private val factory = ExpressTransactionAssetFactory( + multiAccountListSupplier = multiAccountListSupplier, + userWalletsListRepository = userWalletsListRepository, + tokenInfoRepository = tokenInfoRepository, + excludedBlockchains = ExcludedBlockchains(), + ) + + @BeforeEach + fun setup() { + clearMocks(tokenInfoRepository, multiAccountListSupplier, userWalletsListRepository) + every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { tokenInfoRepository.getCached(any()) } returns emptyList() + } + + @Test + fun `GIVEN token only in cache WHEN create THEN builds token from cache`() = runTest { + // Arrange + coEvery { tokenInfoRepository.getCached(any()) } returns listOf( + TokenInfoEntity( + networkId = "ethereum", + contractAddress = TOKEN_CONTRACT, + coinId = "tether", + name = "Tether", + symbol = "USDT", + decimals = 6, + updatedAt = 0, + ), + ) + + // Act + val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList()) + + // Assert + val token = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT)] + assertThat(token).isInstanceOf(CryptoCurrency.Token::class.java) + assertThat((token as CryptoCurrency.Token).symbol).isEqualTo("USDT") + } + + @Test + fun `GIVEN coin asset WHEN create THEN builds coin`() = runTest { + // Act + val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList()) + + // Assert + val coin = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE)] + assertThat(coin).isInstanceOf(CryptoCurrency.Coin::class.java) + } + + @Test + fun `GIVEN token absent from portfolio and cache WHEN create THEN omits it`() = runTest { + // Act + val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList()) + + // Assert + assertThat(result).doesNotContainKey(ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT)) + } + + /** A swap from a native coin (empty contract) to an Ethereum token. */ + private fun coinToTokenSwap() = ExpressExchangeEntity( + txId = "tx-1", + ownerAddress = "owner", + providerId = "provider", + fromAddress = "owner", + payinAddress = "payin-addr", + payinExtraId = null, + payoutAddress = "payout-addr", + refundAddress = null, + refundExtraId = null, + rateType = "float", + status = "finished", + externalTxId = null, + externalTxUrl = null, + payinHash = "payin", + payoutHash = "payout", + refundNetwork = null, + refundContractAddress = null, + createdAt = "2026-06-01T00:00:00Z", + updatedAt = "2026-06-01T00:00:00Z", + payTill = null, + averageDuration = null, + from = ExpressExchangeEntity.AssetEmbedded( + contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE, + network = "ethereum", + decimals = 18, + amount = "1.0", + actualAmount = null, + ), + to = ExpressExchangeEntity.AssetEmbedded( + contractAddress = TOKEN_CONTRACT, + network = "ethereum", + decimals = 6, + amount = "100.0", + actualAmount = null, + ), + ) + + private companion object { + const val TOKEN_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepositoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepositoryTest.kt new file mode 100644 index 0000000000..995524ad76 --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/TokenInfoRepositoryTest.kt @@ -0,0 +1,216 @@ +package com.tangem.data.txhistory.repository.factory + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +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.CoinsResponse +import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao +import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TokenInfoRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + private val tokenInfoDao: TokenInfoDao = mockk(relaxUnitFun = true) + private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + + private val repository = TokenInfoRepository( + tangemTechApi = tangemTechApi, + tokenInfoDao = tokenInfoDao, + multiAccountListSupplier = multiAccountListSupplier, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun setup() { + clearMocks(tangemTechApi, tokenInfoDao, multiAccountListSupplier) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf())) + } + + @Test + fun `GIVEN only coins WHEN fetchMissing THEN nothing is read or fetched`() = runTest { + // Act + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = COIN_CONTRACT))) + + // Assert + coVerify(exactly = 0) { tokenInfoDao.getCached(any(), any(), any()) } + coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) } + coVerify(exactly = 0) { tokenInfoDao.upsert(any()) } + } + + @Test + fun `GIVEN token already fresh in cache WHEN fetchMissing THEN does not fetch`() = runTest { + // Arrange + coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns listOf( + tokenInfoEntity(networkId = "ethereum", contractAddress = "0xUSDT"), + ) + + // Act + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT"))) + + // Assert + coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) } + coVerify(exactly = 0) { tokenInfoDao.upsert(any()) } + } + + @Test + fun `GIVEN token already in portfolio WHEN fetchMissing THEN does not fetch`() = runTest { + // Arrange + coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList() + val portfolioToken = MockCryptoCurrencyFactory().createToken( + blockchain = Blockchain.Ethereum, + contractAddress = "0xPortfolioToken", + ) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf(portfolioToken))) + + // Act — same contract, different casing + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0XPORTFOLIOTOKEN"))) + + // Assert + coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) } + coVerify(exactly = 0) { tokenInfoDao.upsert(any()) } + } + + @Test + fun `GIVEN unresolved token WHEN fetchMissing THEN fetches and caches it`() = runTest { + // Arrange + coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList() + coEvery { + tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) + } returns ApiResponse.Success( + coinsResponse(coin(network("ethereum", "0xUSDT", BigDecimal(6)))), + ) + val saved = slot>() + coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit + + // Act + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT"))) + + // Assert + val entity = saved.captured.single() + assertThat(entity.copy(updatedAt = 0)).isEqualTo( + TokenInfoEntity( + networkId = "ethereum", + contractAddress = "0xUSDT", + coinId = COIN_ID, + name = COIN_NAME, + symbol = COIN_SYMBOL, + decimals = 6, + updatedAt = 0, + ), + ) + } + + @Test + fun `GIVEN response with extra networks WHEN fetchMissing THEN keeps only requested pairs ignoring case`() = runTest { + // Arrange + coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList() + coEvery { + tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) + } returns ApiResponse.Success( + coinsResponse( + coin( + network("ethereum", "0xabc", BigDecimal(6)), // requested (different case) + network("bsc", "0xabc", BigDecimal(18)), // other network — not requested + network("ethereum", "0xother", null), // missing decimals — dropped + ), + ), + ) + val saved = slot>() + coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit + + // Act + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xAbC"))) + + // Assert + assertThat(saved.captured.map { it.networkId to it.contractAddress }) + .containsExactly("ethereum" to "0xabc") + } + + @Test + fun `GIVEN getCoins fails WHEN fetchMissing THEN nothing is cached`() = runTest { + // Arrange + coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList() + coEvery { + tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) + } returns ApiResponse.Error( + ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR, + message = "boom", + errorBody = null, + ), + ).cast() + + // Act + repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT"))) + + // Assert + coVerify(exactly = 0) { tokenInfoDao.upsert(any()) } + } + + private fun accountListOf(vararg currencies: CryptoCurrency): AccountList = mockk { + every { flattenCurrencies() } returns currencies.toList() + } + + private fun tokenInfoEntity(networkId: String, contractAddress: String) = TokenInfoEntity( + networkId = networkId, + contractAddress = contractAddress, + coinId = COIN_ID, + name = COIN_NAME, + symbol = COIN_SYMBOL, + decimals = 6, + updatedAt = 0, + ) + + private fun coinsResponse(vararg coins: CoinsResponse.Coin) = CoinsResponse( + imageHost = null, + coins = coins.toList(), + total = coins.size, + ) + + private fun coin(vararg networks: CoinsResponse.Coin.Network) = CoinsResponse.Coin( + id = COIN_ID, + name = COIN_NAME, + symbol = COIN_SYMBOL, + active = true, + networks = networks.toList(), + ) + + private fun network(networkId: String, contractAddress: String?, decimalCount: BigDecimal?) = + CoinsResponse.Coin.Network( + networkId = networkId, + contractAddress = contractAddress, + decimalCount = decimalCount, + exchangeable = true, + ) + + @Suppress("UNCHECKED_CAST") + private fun ApiResponse.Error.cast(): ApiResponse = this as ApiResponse + + private companion object { + const val COIN_CONTRACT = "0" + const val COIN_ID = "tether" + const val COIN_NAME = "Tether" + const val COIN_SYMBOL = "USDT" + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 83becd8f0c..fd48b003b1 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -15,15 +15,14 @@ import com.tangem.datasource.api.express.models.request.PairsRequestBody import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails import com.tangem.datasource.api.express.models.response.SwapPair import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders +import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.datasource.api.express.models.response.TxDetails import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.exchangeservice.swap.ExpressUtils -import com.tangem.datasource.local.converter.toEntity import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.storeObject -import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet @@ -50,7 +49,7 @@ internal class DefaultSwapRepository( private val errorsDataConverter: ErrorsDataConverter, private val dataSignatureVerifier: DataSignatureVerifier, private val appPreferencesStore: AppPreferencesStore, - private val expressHistoryDao: ExpressHistoryDao, + private val expressHistoryRepository: ExpressHistoryRepository, private val txHistoryFeatureToggles: TxHistoryFeatureToggles, moshi: Moshi, ) : SwapRepository { @@ -157,9 +156,11 @@ internal class DefaultSwapRepository( ) .getOrThrow() - val entity = response.toEntity(ownerAddress = response.fromAddress.orEmpty()) if (txHistoryFeatureToggles.isNewTxHistoryEnabled) { - expressHistoryDao.upsertExchanges(listOf(entity)) + expressHistoryRepository.storeExchanges( + ownerAddress = response.fromAddress.orEmpty(), + items = listOf(response), + ) } exchangeStatusConverter.convert(response) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index aa018bb971..602ef02aae 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.di import com.squareup.moshi.Moshi import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.datasource.api.surveysparrow.SurveySparrowApi @@ -10,7 +11,6 @@ import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.feature.swap.DefaultSwapFeedbackRepository @@ -41,7 +41,7 @@ internal class SwapDataModule { errorsDataConverter: ErrorsDataConverter, @NetworkMoshi moshi: Moshi, appPreferencesStore: AppPreferencesStore, - expressHistoryDao: ExpressHistoryDao, + expressHistoryRepository: ExpressHistoryRepository, txHistoryFeatureToggles: TxHistoryFeatureToggles, ): SwapRepository { return DefaultSwapRepository( @@ -51,7 +51,7 @@ internal class SwapDataModule { dataSignatureVerifier = dataSignature, moshi = moshi, appPreferencesStore = appPreferencesStore, - expressHistoryDao = expressHistoryDao, + expressHistoryRepository = expressHistoryRepository, txHistoryFeatureToggles = txHistoryFeatureToggles, ) }