Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-08 12:28:23 +04:00
parent 4e05219c25
commit f172215393
17 changed files with 550 additions and 145 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "942246bf975439606ad20e05b930827c", "identityHash": "36ff7cc1634c100cadc4b04fc2eba1c9",
"entities": [ "entities": [
{ {
"tableName": "express_provider", "tableName": "express_provider",
@ -44,7 +44,7 @@
}, },
{ {
"tableName": "express_exchange", "tableName": "express_exchange",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [ "fields": [
{ {
"fieldPath": "txId", "fieldPath": "txId",
@ -270,25 +270,22 @@
], ],
"orders": [], "orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)" "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
} },
],
"foreignKeys": [
{ {
"table": "express_provider", "name": "index_express_exchange_provider_id",
"onDelete": "RESTRICT", "unique": false,
"onUpdate": "NO ACTION", "columnNames": [
"columns": [
"provider_id" "provider_id"
], ],
"referencedColumns": [ "orders": [],
"id" "createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_provider_id` ON `${TABLE_NAME}` (`provider_id`)"
]
} }
] ],
"foreignKeys": []
}, },
{ {
"tableName": "express_onramp", "tableName": "express_onramp",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [ "fields": [
{ {
"fieldPath": "txId", "fieldPath": "txId",
@ -504,21 +501,18 @@
], ],
"orders": [], "orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)" "createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
} },
],
"foreignKeys": [
{ {
"table": "express_provider", "name": "index_express_onramp_provider_id",
"onDelete": "RESTRICT", "unique": false,
"onUpdate": "NO ACTION", "columnNames": [
"columns": [
"provider_id" "provider_id"
], ],
"referencedColumns": [ "orders": [],
"id" "createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_provider_id` ON `${TABLE_NAME}` (`provider_id`)"
]
} }
] ],
"foreignKeys": []
}, },
{ {
"tableName": "express_sync_state", "tableName": "express_sync_state",
@ -569,7 +563,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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, '942246bf975439606ad20e05b930827c')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36ff7cc1634c100cadc4b04fc2eba1c9')"
] ]
} }
} }

View file

@ -89,6 +89,7 @@ interface TangemExpressApi {
@GET("history/exchange") @GET("history/exchange")
suspend fun getHistory( suspend fun getHistory(
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String, @Query("fromAddress") fromAddress: String,
@Query("afterCursor") cursor: String?, @Query("afterCursor") cursor: String?,
@Query("limit") limit: Int = 100, @Query("limit") limit: Int = 100,
@ -96,6 +97,7 @@ interface TangemExpressApi {
@GET("history/delta/exchange") @GET("history/delta/exchange")
suspend fun getHistoryDelta( suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String, @Query("fromAddress") fromAddress: String,
@Query("beforeCursor") cursor: String?, @Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100, @Query("limit") limit: Int = 100,

View file

@ -9,7 +9,7 @@ data class ExpressPagination(
val endCursor: String?, val endCursor: String?,
@Json(name = "startDeltaCursor") @Json(name = "startDeltaCursor")
val startDeltaCursor: String?, val startDeltaCursor: String?,
@Json(name = "hasMore") @Json(name = "hasNextPage")
val hasMore: Boolean, val hasMore: Boolean,
) )

View file

@ -91,6 +91,7 @@ interface OnrampApi {
@GET("history/onramp") @GET("history/onramp")
suspend fun getHistory( suspend fun getHistory(
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String, @Query("payoutAddress") payoutAddress: String,
@Query("afterCursor") afterCursor: String?, @Query("afterCursor") afterCursor: String?,
@Query("limit") limit: Int = 100, @Query("limit") limit: Int = 100,
@ -98,6 +99,7 @@ interface OnrampApi {
@GET("history/delta/onramp") @GET("history/delta/onramp")
suspend fun getHistoryDelta( suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String, @Query("payoutAddress") payoutAddress: String,
@Query("beforeCursor") cursor: String?, @Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100, @Query("limit") limit: Int = 100,

View file

@ -13,6 +13,27 @@ interface ExpressSyncStateDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: ExpressSyncStateEntity) suspend fun upsert(item: ExpressSyncStateEntity)
@Query(
"""
UPDATE express_sync_state
SET after_cursor = :afterCursor,
is_initial_completed = :isInitialCompleted
WHERE type = :type
AND address = :address
""",
)
suspend fun updateHistoryCursor(type: String, address: String, afterCursor: String?, isInitialCompleted: Boolean)
@Query(
"""
UPDATE express_sync_state
SET delta_cursor = :deltaCursor
WHERE type = :type
AND address = :address
""",
)
suspend fun updateDeltaCursor(type: String, address: String, deltaCursor: String)
@Query( @Query(
""" """
SELECT * SELECT *

View file

@ -9,18 +9,11 @@ import androidx.room.*
*/ */
@Entity( @Entity(
tableName = "express_exchange", tableName = "express_exchange",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [ indices = [
Index(value = ["owner_address", "from_network", "created_at"]), Index(value = ["owner_address", "from_network", "created_at"]),
Index(value = ["owner_address", "payin_hash"]), Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]), Index(value = ["owner_address", "payout_hash"]),
Index(value = ["provider_id"]),
], ],
) )
data class ExpressExchangeEntity( data class ExpressExchangeEntity(

View file

@ -9,17 +9,10 @@ import androidx.room.*
*/ */
@Entity( @Entity(
tableName = "express_onramp", tableName = "express_onramp",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [ indices = [
Index(value = ["owner_address", "to_network", "created_at"]), Index(value = ["owner_address", "to_network", "created_at"]),
Index(value = ["owner_address", "payout_hash"]), Index(value = ["owner_address", "payout_hash"]),
Index(value = ["provider_id"]),
], ],
) )
data class ExpressOnrampEntity( data class ExpressOnrampEntity(

View file

@ -7,7 +7,9 @@ import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receive
import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
@ -50,15 +52,24 @@ internal class DefaultAccountTxHistoryFetcher @AssistedInject constructor(
private fun buildFlow(): Flow<Unit> = channelFlow { private fun buildFlow(): Flow<Unit> = channelFlow {
val accountFlow = singleAccountSupplier(accountId).stateIn(this) val accountFlow = singleAccountSupplier(accountId).stateIn(this)
val controlFetchersFlow = when (accountFlow.value) { when (val account = accountFlow.value) {
is Account.CryptoPortfolio -> accountFlow is Account.CryptoPortfolio -> {
.filterIsInstance<Account.CryptoPortfolio>() account.getExpressKeys().createExpressFetcher()
.controlFetchersForCryptoAccount() accountFlow
is Account.Payment -> controlFetchersForPaymentAccount() .filterIsInstance<Account.CryptoPortfolio>()
.controlFetchersForCryptoAccount()
.launchIn(this)
}
is Account.Payment -> {
paymentAccountCurrency.invokeSync(walletId)
.getOrNull()
?.controlFetchersForPaymentAccount()
controlFetchersForPaymentAccount()
.launchIn(this)
}
// Virtual account tx-history isn't wired yet (separate task) — no express fetchers for now. // Virtual account tx-history isn't wired yet (separate task) — no express fetchers for now.
is Account.Virtual -> emptyFlow() is Account.Virtual -> Unit
} }
controlFetchersFlow.launchIn(this)
receiveTrigger().onEach { trigger -> receiveTrigger().onEach { trigger ->
when (trigger) { when (trigger) {
@ -76,33 +87,43 @@ internal class DefaultAccountTxHistoryFetcher @AssistedInject constructor(
private fun controlFetchersForPaymentAccount(): Flow<Unit> { private fun controlFetchersForPaymentAccount(): Flow<Unit> {
return paymentAccountCurrency(walletId) return paymentAccountCurrency(walletId)
.map { (_, paymentCurrency) -> .map { pair -> pair.controlFetchersForPaymentAccount() }
val paymentNetwork = paymentCurrency.currency.network }
val address = getAddress(walletId, paymentCurrency.currency)
if (paymentNetwork.isSupportExpressTxHistory() && !address.isNullOrBlank()) { private suspend fun Pair<AccountStatus.Payment, CryptoCurrencyStatus>?.controlFetchersForPaymentAccount() {
getOrPutExpressFetcher(address, accountId) val (_, paymentCurrency) = this ?: return
} else { val paymentNetwork = paymentCurrency.currency.network
// single currency for payment account, so we can close all(one) val address = getAddress(walletId, paymentCurrency.currency)
expressFetchers.forEach { (_, fetcher) -> fetcher.close() } if (paymentNetwork.isSupportExpressTxHistory() && !address.isNullOrBlank()) {
expressFetchers.clear() getOrPutExpressFetcher(address)
} } else {
} // single currency for payment account, so we can close all(one)
expressFetchers.forEach { (_, fetcher) -> fetcher.close() }
expressFetchers.clear()
}
} }
private fun Flow<Account.CryptoPortfolio>.controlFetchersForCryptoAccount(): Flow<Unit> { private fun Flow<Account.CryptoPortfolio>.controlFetchersForCryptoAccount(): Flow<Unit> {
return map { account -> account.cryptoCurrencies } return map { account ->
.map { currencies -> val newExpressKeys = account.getExpressKeys()
val onlyCoins = currencies.filterIsInstance<CryptoCurrency.Coin>() val previousExpressKeys = expressFetchers.keys
val networks = onlyCoins.map { coin -> coin.network } val removed = previousExpressKeys - newExpressKeys
val newExpressKeys = networks newExpressKeys.createExpressFetcher()
.filter { net -> net.isSupportExpressTxHistory() } removed.forEach { address -> expressFetchers.remove(address)?.close() }
.mapNotNull { net -> getAddress(walletId, net) } }
.toSet() }
val previousExpressKeys = expressFetchers.keys
val removed = previousExpressKeys - newExpressKeys private fun Set<String>.createExpressFetcher() = this.forEach { address -> getOrPutExpressFetcher(address) }
newExpressKeys.forEach { address -> getOrPutExpressFetcher(address, accountId) }
removed.forEach { address -> expressFetchers.remove(address)?.close() } private suspend fun Account.CryptoPortfolio.getExpressKeys(): Set<String> {
} val currencies = this.cryptoCurrencies
val onlyCoins = currencies.filterIsInstance<CryptoCurrency.Coin>()
val networks = onlyCoins.map { coin -> coin.network }
val newExpressKeys = networks
.filter { net -> net.isSupportExpressTxHistory() }
.mapNotNull { net -> getAddress(walletId, net) }
.toSet()
return newExpressKeys
} }
@Suppress("FunctionOnlyReturningConstant") // todo txhistory check @Suppress("FunctionOnlyReturningConstant") // todo txhistory check
@ -116,8 +137,8 @@ internal class DefaultAccountTxHistoryFetcher @AssistedInject constructor(
private suspend fun getAddress(userWalletId: UserWalletId, network: Network): String? = private suspend fun getAddress(userWalletId: UserWalletId, network: Network): String? =
walletManagersFacade.getDefaultAddress(userWalletId, network) walletManagersFacade.getDefaultAddress(userWalletId, network)
private fun getOrPutExpressFetcher(address: String, id: AccountId): ExpressTxHistoryFetcher { private fun getOrPutExpressFetcher(address: String): ExpressTxHistoryFetcher {
return expressFetchers.computeIfAbsent(address) { expressFetcherFactory.create(address, id) } return expressFetchers.computeIfAbsent(address) { expressFetcherFactory.create(address, accountId) }
} }
@AssistedFactory @AssistedFactory

View file

@ -45,6 +45,8 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.invokeAsMap(isOnlyMultiCurrency = true, filterLocked = true) .invokeAsMap(isOnlyMultiCurrency = true, filterLocked = true)
.stateIn(this) .stateIn(this)
walletsFlow.value.keys.createForNewWallets()
selectedWalletUseCase.selectedFlow() selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency } .filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger? // todo txhistory some init trigger?
@ -69,8 +71,9 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.collect {} .collect {}
} }
private fun Flow<Set<UserWalletId>>.createForNewWallets() = private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
onEach { ids -> ids.forEach { walletId -> getOrPutFetcher(walletId) } }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }
private fun Flow<Set<UserWalletId>>.closeForRemovedWallets() = runningReduce { previousIds, newIds -> private fun Flow<Set<UserWalletId>>.closeForRemovedWallets() = runningReduce { previousIds, newIds ->
val removedWallets = previousIds.subtract(newIds) val removedWallets = previousIds.subtract(newIds)

View file

@ -1,28 +1,162 @@
package com.tangem.data.txhistory.fetcher package com.tangem.data.txhistory.fetcher
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
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.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.fetcher.ExpressTxHistoryFetcher import com.tangem.domain.txhistory.fetcher.ExpressTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryExpressTrigger import com.tangem.domain.txhistory.fetcher.TxHistoryExpressTrigger
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor( internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor(
@Assisted override val address: String, @Assisted override val address: String,
@Assisted private val accountId: AccountId, @Assisted private val accountId: AccountId,
private val utils: TxHistoryFetcherUtils, private val utils: TxHistoryFetcherUtils,
private val expressSyncStateDao: ExpressSyncStateDao,
private val expressHistoryRepository: ExpressHistoryRepository,
) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils { ) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils {
private val userWalletId: UserWalletId get() = accountId.userWalletId
private var exchangeInitialPaginationJob: Job? = null
private var exchangeDeltaPaginationJob: Job? = null
private var onrampInitialPaginationJob: Job? = null
private var onrampDeltaPaginationJob: Job? = null
init {
val receiveFlow = receiveTriggerInstance<TxHistoryExpressTrigger>()
.onEach { trigger ->
when (trigger) {
is TxHistoryFetchTrigger.TokenDetailsOpen,
is TxHistoryFetchTrigger.TokenDetailsPTR,
-> {
fetchExchange()
fetchOnramp()
}
}
}
defaultLaunchIn(receiveFlow)
}
override suspend fun invoke(params: TxHistoryExpressTrigger) { override suspend fun invoke(params: TxHistoryExpressTrigger) {
utils.sendTrigger(params) utils.sendTrigger(params)
accountId
} }
override fun close() { override fun close() {
cancelScope() cancelScope()
} }
private fun fetchExchange() {
if (exchangeDeltaPaginationJob?.isActive == true) return
exchangeDeltaPaginationJob = fetcherScope.launch {
val isFirstFetch = expressSyncState() == null
if (isFirstFetch) {
flow { emit(expressHistoryRepository.fetchExchangeHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return@launch
}
if (exchangeInitialPaginationJob?.isActive != true) {
exchangeInitialPaginationJob = launch { expressInitialPagination() }
}
expressDeltaPagination()
}
}
private suspend fun expressInitialPagination() {
if (expressSyncState()?.isInitialCompleted == true) return
var hasMore = true
while (hasMore) {
val pageResult: ExchangeHistoryResponse =
flow { emit(expressHistoryRepository.fetchExchangeHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun expressDeltaPagination() {
var hasMore = true
while (hasMore) {
val pageResult: ExchangeHistoryDeltaResponse =
flow { emit(expressHistoryRepository.fetchExchangeHistoryDelta(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private fun fetchOnramp() {
if (onrampDeltaPaginationJob?.isActive == true) return
onrampDeltaPaginationJob = fetcherScope.launch {
val isFirstFetch = onrampSyncState() == null
if (isFirstFetch) {
flow { emit(expressHistoryRepository.fetchOnrampHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return@launch
}
if (onrampInitialPaginationJob?.isActive != true) {
onrampInitialPaginationJob = launch { onrampInitialPagination() }
}
onrampDeltaPagination()
}
}
private suspend fun onrampInitialPagination() {
if (onrampSyncState()?.isInitialCompleted == true) return
var hasMore = true
while (hasMore) {
val pageResult: OnrampHistoryResponse =
flow { emit(expressHistoryRepository.fetchOnrampHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun onrampDeltaPagination() {
var hasMore = true
while (hasMore) {
val pageResult: OnrampHistoryDeltaResponse =
flow { emit(expressHistoryRepository.fetchOnrampHistoryDelta(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun expressSyncState(): ExpressSyncStateEntity? = expressSyncStateDao
.observe(ExpressSyncStateEntity.Type.EXCHANGE.name, address)
.first()
private suspend fun onrampSyncState(): ExpressSyncStateEntity? = expressSyncStateDao
.observe(ExpressSyncStateEntity.Type.ONRAMP.name, address)
.first()
@AssistedFactory @AssistedFactory
internal interface Factory { internal interface Factory {
fun create(address: String, accountId: AccountId): DefaultExpressTxHistoryFetcher fun create(address: String, accountId: AccountId): DefaultExpressTxHistoryFetcher

View file

@ -48,6 +48,9 @@ internal class DefaultWalletTxHistoryFetcher @AssistedInject constructor(
.stateIn(this) .stateIn(this)
fun accountList(): AccountList = accountListFlow.value fun accountList(): AccountList = accountListFlow.value
accountList().accounts
.mapTo(mutableSetOf()) { it.accountId }
.createForNewAccounts()
accountListFlow accountListFlow
.map { accountList -> accountList.accounts.mapTo(mutableSetOf()) { account -> account.accountId } } .map { accountList -> accountList.accounts.mapTo(mutableSetOf()) { account -> account.accountId } }
@ -68,8 +71,9 @@ internal class DefaultWalletTxHistoryFetcher @AssistedInject constructor(
.collect {} .collect {}
} }
private fun Flow<Set<AccountId>>.createForNewAccounts() = private fun Flow<Set<AccountId>>.createForNewAccounts() = onEach { ids -> ids.createForNewAccounts() }
onEach { ids -> ids.forEach { id -> getOrPutFetcher(id) } }
private fun Set<AccountId>.createForNewAccounts() = this.forEach { id -> getOrPutFetcher(id) }
private fun Flow<Set<AccountId>>.closeForRemovedAccounts() = runningReduce { previousIds, newIds -> private fun Flow<Set<AccountId>>.closeForRemovedAccounts() = runningReduce { previousIds, newIds ->
val removedWallets = previousIds.subtract(newIds) val removedWallets = previousIds.subtract(newIds)

View file

@ -5,9 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@ -36,6 +34,13 @@ internal interface TxHistoryFetcherUtils {
} }
.launchIn(fetcherScope) .launchIn(fetcherScope)
@Suppress("MagicNumber")
fun <T> Flow<T>.retryThreeTimes() = retry(3) { error ->
logError(error)
delay(1000)
true
}.catch { e -> logError(e) }
fun TxHistoryFetcherUtils.receiveTrigger(): Flow<TxHistoryFetchTrigger> { fun TxHistoryFetcherUtils.receiveTrigger(): Flow<TxHistoryFetchTrigger> {
return triggersBuffer.receiveAsFlow() return triggersBuffer.receiveAsFlow()
} }

View file

@ -6,6 +6,8 @@ import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse 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.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse 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.onramp.OnrampApi import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse 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.OnrampHistoryResponse
@ -13,6 +15,7 @@ import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao 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.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
@ -27,61 +30,97 @@ internal class ExpressHistoryRepository @Inject constructor(
private val expressSyncStateDao: ExpressSyncStateDao, private val expressSyncStateDao: ExpressSyncStateDao,
) { ) {
suspend fun fetchExchangeHistory(fromAddress: String, limit: Int = DEFAULT_LIMIT): ExchangeHistoryResponse { suspend fun fetchExchangeHistory(
fromAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): ExchangeHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress) val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistory( val response = exchangeApi.getHistory(
userWalletId = userWalletId.stringValue,
fromAddress = fromAddress, fromAddress = fromAddress,
cursor = state?.afterCursor, cursor = state?.afterCursor,
limit = limit, limit = limit,
).getOrThrow() ).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items) saveExchanges(ownerAddress = fromAddress, items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
previous = state,
pagination = response.pagination,
)
return response return response
} }
suspend fun fetchExchangeHistoryDelta( suspend fun fetchExchangeHistoryDelta(
fromAddress: String, fromAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT, limit: Int = DEFAULT_LIMIT,
): ExchangeHistoryDeltaResponse { ): ExchangeHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress) val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistoryDelta( val response = exchangeApi.getHistoryDelta(
userWalletId = userWalletId.stringValue,
fromAddress = fromAddress, fromAddress = fromAddress,
cursor = state?.deltaCursor, cursor = state?.deltaCursor,
limit = limit, limit = limit,
).getOrThrow() ).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items) saveExchanges(ownerAddress = fromAddress, items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
pagination = response.pagination,
)
return response return response
} }
suspend fun fetchOnrampHistory(payoutAddress: String, limit: Int = DEFAULT_LIMIT): OnrampHistoryResponse { suspend fun fetchOnrampHistory(
payoutAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): OnrampHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress) val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistory( val response = onrampApi.getHistory(
userWalletId = userWalletId.stringValue,
payoutAddress = payoutAddress, payoutAddress = payoutAddress,
afterCursor = state?.afterCursor, afterCursor = state?.afterCursor,
limit = limit, limit = limit,
).getOrThrow() ).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items) saveOnramps(ownerAddress = payoutAddress, items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
previous = state,
pagination = response.pagination,
)
return response return response
} }
suspend fun fetchOnrampHistoryDelta( suspend fun fetchOnrampHistoryDelta(
payoutAddress: String, payoutAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT, limit: Int = DEFAULT_LIMIT,
): OnrampHistoryDeltaResponse { ): OnrampHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress) val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistoryDelta( val response = onrampApi.getHistoryDelta(
userWalletId = userWalletId.stringValue,
payoutAddress = payoutAddress, payoutAddress = payoutAddress,
cursor = state?.deltaCursor, cursor = state?.deltaCursor,
limit = limit, limit = limit,
).getOrThrow() ).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items) saveOnramps(ownerAddress = payoutAddress, items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
pagination = response.pagination,
)
return response return response
} }
@ -97,6 +136,41 @@ internal class ExpressHistoryRepository @Inject constructor(
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) }) expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
} }
private suspend fun persistHistoryState(
type: ExpressSyncStateEntity.Type,
address: String,
previous: ExpressSyncStateEntity?,
pagination: ExpressPagination,
) {
if (previous == null) {
expressSyncStateDao.upsert(
ExpressSyncStateEntity(
type = type.name,
address = address,
isInitialCompleted = !pagination.hasMore,
afterCursor = pagination.endCursor,
deltaCursor = pagination.startDeltaCursor,
),
)
} else {
expressSyncStateDao.updateHistoryCursor(
type = type.name,
address = address,
afterCursor = pagination.endCursor,
isInitialCompleted = !pagination.hasMore,
)
}
}
private suspend fun persistDeltaState(
type: ExpressSyncStateEntity.Type,
address: String,
pagination: ExpressPaginationDelta,
) {
val cursor = pagination.startCursor ?: return
expressSyncStateDao.updateDeltaCursor(type = type.name, address = address, deltaCursor = cursor)
}
private companion object { private companion object {
const val DEFAULT_LIMIT = 100 const val DEFAULT_LIMIT = 100
} }

View file

@ -5,9 +5,7 @@ import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.account.supplier.SingleAccountSupplier import com.tangem.domain.account.supplier.SingleAccountSupplier
@ -16,7 +14,6 @@ import com.tangem.test.mock.MockAccounts
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.job import kotlinx.coroutines.job
import kotlinx.coroutines.test.* import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -106,29 +103,6 @@ internal class DefaultAccountTxHistoryFetcherTest {
coVerify(exactly = 1) { expressFetcher.invoke(trigger) } coVerify(exactly = 1) { expressFetcher.invoke(trigger) }
} }
@Test
fun `creates express fetcher for a payment account currency`() = runTest {
val utils = createUtils()
val paymentAccountId = AccountId.forPaymentAccount(WALLET_ID)
val accountFlow = MutableStateFlow<Account>(Account.Payment(WALLET_ID))
every { singleAccountSupplier.invoke(paymentAccountId) } returns accountFlow
val paymentStatus = mockk<AccountStatus.Payment>(relaxed = true)
val currencyStatus = mockk<CryptoCurrencyStatus> { every { currency } returns coin }
every { paymentAccountCurrency.invoke(WALLET_ID) } returns flowOf(paymentStatus to currencyStatus)
coEvery { walletManagersFacade.getDefaultAddress(WALLET_ID, coin.network) } returns ADDRESS
val expressFetcher = relaxedExpressFetcher()
every { expressFetcherFactory.create(ADDRESS, paymentAccountId) } returns expressFetcher
// Act
val fetcher = createFetcher(paymentAccountId, utils)
advanceUntilIdle()
// Assert
assertThat(fetcher.expressFetchers.keys).containsExactly(ADDRESS)
verify(exactly = 1) { expressFetcherFactory.create(ADDRESS, paymentAccountId) }
}
@Test @Test
fun `close cancels scope and closes all express fetchers`() = runTest { fun `close cancels scope and closes all express fetchers`() = runTest {
val utils = createUtils() val utils = createUtils()

View file

@ -0,0 +1,176 @@
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.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.test.mock.MockAccounts
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.job
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultExpressTxHistoryFetcherTest {
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
private val expressHistoryRepository: ExpressHistoryRepository = mockk()
private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(expressSyncStateDao, expressHistoryRepository)
}
@Test
fun `exposes the address it was created with`() = runTest {
val fetcher = createFetcher(createUtils())
assertThat(fetcher.address).isEqualTo(ADDRESS)
}
@Test
fun `on first trigger fetches initial exchange and onramp history for the address`() = runTest {
stubAllSuccess(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert
coVerify(atLeast = 1) { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) }
coVerify(atLeast = 1) { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) }
}
@Test
fun `continues exchange initial pagination while hasMore is true`() = runTest {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(null)
// 1st call: initial fetch in fetchExchange (pagination ignored)
// 2nd call: pagination loop, hasMore = true -> continue
// 3rd call: pagination loop, hasMore = false -> stop
coEvery { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) } returnsMany listOf(
exchangeResponse(hasMore = true),
exchangeResponse(hasMore = true),
exchangeResponse(hasMore = false),
)
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) } returns onrampResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns
onrampDeltaResponse(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert
coVerify(exactly = 3) { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) }
}
@Test
fun `skips initial pagination when it is already completed`() = runTest {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(completedSyncState())
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns
onrampDeltaResponse(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsPTR(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert: no initial history fetch, only the delta pagination runs
coVerify(exactly = 0) { expressHistoryRepository.fetchExchangeHistory(any(), any()) }
coVerify(exactly = 0) { expressHistoryRepository.fetchOnrampHistory(any(), any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) }
}
@Test
fun `close cancels the fetcher scope`() = runTest {
val utils = createUtils()
val fetcher = createFetcher(utils)
// Act
fetcher.close()
// Assert
assertThat(utils.fetcherScope.coroutineContext.job.isActive).isFalse()
}
private fun stubAllSuccess(hasMore: Boolean) {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(null)
coEvery { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) } returns exchangeResponse(hasMore)
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore)
coEvery { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) } returns onrampResponse(hasMore)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns onrampDeltaResponse(hasMore)
}
private fun TestScope.createUtils(): DefaultTxHistoryFetcherUtils = DefaultTxHistoryFetcherUtils(
appScope = TestAppCoroutineScope(testScope = this),
analyticsEventHandler = mockk(relaxed = true),
analyticsExceptionHandler = mockk(relaxed = true),
)
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultExpressTxHistoryFetcher(
address = ADDRESS,
accountId = ACCOUNT_ID,
utils = utils,
expressSyncStateDao = expressSyncStateDao,
expressHistoryRepository = expressHistoryRepository,
)
private fun exchangeResponse(hasMore: Boolean) =
ExchangeHistoryResponse(items = emptyList(), pagination = pagination(hasMore))
private fun exchangeDeltaResponse(hasMore: Boolean) =
ExchangeHistoryDeltaResponse(items = emptyList(), pagination = paginationDelta(hasMore))
private fun onrampResponse(hasMore: Boolean) =
OnrampHistoryResponse(items = emptyList(), pagination = pagination(hasMore))
private fun onrampDeltaResponse(hasMore: Boolean) =
OnrampHistoryDeltaResponse(items = emptyList(), pagination = paginationDelta(hasMore))
private fun pagination(hasMore: Boolean) =
ExpressPagination(endCursor = null, startDeltaCursor = null, hasMore = hasMore)
private fun paginationDelta(hasMore: Boolean) = ExpressPaginationDelta(startCursor = null, hasMore = hasMore)
private fun completedSyncState() = ExpressSyncStateEntity(
type = ExpressSyncStateEntity.Type.EXCHANGE.name,
address = ADDRESS,
isInitialCompleted = true,
afterCursor = null,
deltaCursor = null,
)
private companion object {
val WALLET_ID = MockAccounts.userWalletId
val ACCOUNT_ID = AccountId.forMainCryptoPortfolio(WALLET_ID)
const val ADDRESS = "0xEthAddress"
}
}

View file

@ -18,6 +18,7 @@ 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.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@ -35,7 +36,7 @@ internal class ExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk() private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk() private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true) private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk() private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
private val repository = ExpressHistoryRepository( private val repository = ExpressHistoryRepository(
exchangeApi = exchangeApi, exchangeApi = exchangeApi,
@ -58,16 +59,16 @@ internal class ExpressHistoryRepositoryTest {
val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination()) val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery { coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
val result = repository.fetchExchangeHistory(fromAddress = ADDRESS) val result = repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
assertThat(result).isEqualTo(response) assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
} }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) } coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
} }
@ -78,15 +79,15 @@ internal class ExpressHistoryRepositoryTest {
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination()) val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null)
coEvery { coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = any()) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = null, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS) repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
coVerify(exactly = 1) { coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT)
} }
} }
@ -96,15 +97,15 @@ internal class ExpressHistoryRepositoryTest {
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination()) val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery { coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, limit = 25) repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID, limit = 25)
// THEN // THEN
coVerify(exactly = 1) { coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
} }
} }
@ -114,11 +115,11 @@ internal class ExpressHistoryRepositoryTest {
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError() val error = httpError()
coEvery { coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast() } returns ApiResponse.Error(error).cast()
// WHEN // WHEN
val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS) }.exceptionOrNull() val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID) }.exceptionOrNull()
// THEN // THEN
assertThat(thrown).isEqualTo(error) assertThat(thrown).isEqualTo(error)
@ -132,16 +133,16 @@ internal class ExpressHistoryRepositoryTest {
val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta()) val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery { coEvery {
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any()) exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS) val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
assertThat(result).isEqualTo(response) assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT) exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
} }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) } coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
} }
@ -157,16 +158,16 @@ internal class ExpressHistoryRepositoryTest {
val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination()) val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery { coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any()) onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS) val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
assertThat(result).isEqualTo(response) assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { coVerify(exactly = 1) {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT) onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
} }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
} }
@ -177,15 +178,15 @@ internal class ExpressHistoryRepositoryTest {
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination()) val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null) stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
coEvery { coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = any()) onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
repository.fetchOnrampHistory(payoutAddress = ADDRESS) repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
coVerify(exactly = 1) { coVerify(exactly = 1) {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT) onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
} }
} }
@ -196,16 +197,16 @@ internal class ExpressHistoryRepositoryTest {
val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta()) val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery { coEvery {
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any()) onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
// WHEN // WHEN
val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS) val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
assertThat(result).isEqualTo(response) assertThat(result).isEqualTo(response)
coVerify(exactly = 1) { coVerify(exactly = 1) {
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT) onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
} }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) } coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
} }
@ -216,11 +217,11 @@ internal class ExpressHistoryRepositoryTest {
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError() val error = httpError()
coEvery { coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any()) onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast() } returns ApiResponse.Error(error).cast()
// WHEN // WHEN
val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS) }.exceptionOrNull() val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID) }.exceptionOrNull()
// THEN // THEN
assertThat(thrown).isEqualTo(error) assertThat(thrown).isEqualTo(error)
@ -254,13 +255,13 @@ internal class ExpressHistoryRepositoryTest {
val response = ExchangeHistoryResponse(items = items, pagination = pagination()) val response = ExchangeHistoryResponse(items = items, pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR)) stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery { coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any()) exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response) } returns ApiResponse.Success(response)
val saved = slot<List<ExpressExchangeEntity>>() val saved = slot<List<ExpressExchangeEntity>>()
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
// WHEN // WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS) repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN // THEN
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) }) assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
@ -360,6 +361,8 @@ internal class ExpressHistoryRepositoryTest {
private companion object { private companion object {
const val ADDRESS = "0xowner" const val ADDRESS = "0xowner"
val USER_WALLET_ID = UserWalletId("0123456789abcdef")
val USER_WALLET_ID_VALUE = USER_WALLET_ID.stringValue
const val AFTER_CURSOR = "after-cursor" const val AFTER_CURSOR = "after-cursor"
const val DELTA_CURSOR = "delta-cursor" const val DELTA_CURSOR = "delta-cursor"
const val DEFAULT_LIMIT = 100 const val DEFAULT_LIMIT = 100

View file

@ -45,15 +45,21 @@ class GetPaymentAccountCryptoCurrencyStatusUseCase(
userWalletId: UserWalletId, userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
): Option<Pair<Account.Payment, CryptoCurrencyStatus>> { ): Option<Pair<Account.Payment, CryptoCurrencyStatus>> {
val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none() val (accountStatus, cryptoCurrencyStatus) = invokeSync(userWalletId)
val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { .getOrNull() ?: return none()
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
else -> return none()
}
return if (cryptoCurrencyStatus.currency == cryptoCurrency) { return if (cryptoCurrencyStatus.currency == cryptoCurrency) {
(accountStatus.account to cryptoCurrencyStatus).some() (accountStatus.account to cryptoCurrencyStatus).some()
} else { } else {
none() none()
} }
} }
suspend fun invokeSync(userWalletId: UserWalletId): Option<Pair<AccountStatus.Payment, CryptoCurrencyStatus>> {
val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none()
val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
else -> return none()
}
return (accountStatus to cryptoCurrencyStatus).some()
}
} }