Updated on 2026-08-14
This commit is contained in:
parent
0eae1c84fa
commit
066ab573b3
19 changed files with 784 additions and 113 deletions
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<TokenInfoEntity>)
|
||||
|
||||
/**
|
||||
* 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<String>,
|
||||
contractAddresses: Collection<String>,
|
||||
minUpdatedAt: Long = 0,
|
||||
): List<TokenInfoEntity>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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<ExchangeItemResponse>)
|
||||
|
||||
suspend fun storeOnramps(ownerAddress: String, items: List<OnrampItemResponse>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue