Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 13:34:09 +04:00
parent 0eae1c84fa
commit 066ab573b3
19 changed files with 784 additions and 113 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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<ExchangeItemResponse>) {
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<OnrampItemResponse>) {
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<ExchangeItemResponse>) {
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
}
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
private fun fetchMissingTokenInfo(assetIds: Set<ExpressAsset.ID>) {
appScope.launch { tokenInfoRepository.fetchMissing(assetIds) }
}
private suspend fun persistHistoryState(

View file

@ -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<String, TokenInfoEntity>,
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<ExpressAsset.ID>): Map<String, TokenInfoEntity> =
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<CryptoCurrency>.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,

View file

@ -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<ExpressAsset.ID>): List<TokenInfoEntity> = withContext(dispatchers.io) {
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
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<ExpressAsset.ID>) = withContext(dispatchers.io) {
// Coins resolve without the catalog — only tokens are looked up.
val tokenIds = mutableListOf<ExpressAsset.ID>()
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
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<String> = 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<ExpressAsset.ID>, timestamp: Long): List<TokenInfoEntity> {
val requestedPairs = mutableSetOf<String>()
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
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)
}
}

View file

@ -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

View file

@ -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<List<ExpressExchangeEntity>>()
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,

View file

@ -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"
}
}

View file

@ -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<List<TokenInfoEntity>>()
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<List<TokenInfoEntity>>()
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 <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
private companion object {
const val COIN_CONTRACT = "0"
const val COIN_ID = "tether"
const val COIN_NAME = "Tether"
const val COIN_SYMBOL = "USDT"
}
}