Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 17:09:10 +04:00
parent 9efe573323
commit 9af7bad7b8
22 changed files with 1680 additions and 238 deletions

View file

@ -53,6 +53,8 @@ dependencies {
api(projects.domain.walletManager)
api(projects.domain.wallets)
implementation(projects.domain.account.status)
implementation(projects.domain.legacy)
implementation(projects.domain.visa)
// endregion
// region Domain models
@ -60,6 +62,8 @@ dependencies {
implementation(projects.domain.onramp.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.visa.models)
implementation(projects.domain.wallets.models)
// endregion
// region Libs

View file

@ -4,10 +4,12 @@ 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.list.DefaultHistoryTxListManager
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
import com.tangem.domain.txhistory.list.HistoryTxListManager
import com.tangem.domain.txhistory.repository.TxHistoryRepository
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import dagger.Binds
@ -38,4 +40,7 @@ internal interface TxHistoryDataModule {
@Binds
@Singleton
fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository
@Binds
fun provideHistoryTxListManagerFactory(default: DefaultHistoryTxListManager.Factory): HistoryTxListManager.Factory
}

View file

@ -2,13 +2,13 @@ package com.tangem.data.txhistory.fetcher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import javax.inject.Inject
const val TX_HISTORY_TAG = "TxHistory"
@ -30,6 +30,11 @@ internal interface TxHistoryFetcherUtils {
fun <T> TxHistoryFetcherUtils.defaultLaunchIn(flow: Flow<T>) = flow
.retry { error ->
logError(error)
val event = ExceptionAnalyticsEvent(
exception = error,
params = mapOf("source" to TX_HISTORY_TAG),
)
analyticsExceptionHandler.sendException(event)
true
}
.launchIn(fetcherScope)

View file

@ -0,0 +1,178 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package com.tangem.data.txhistory.list
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.data.txhistory.fetcher.TX_HISTORY_TAG
import com.tangem.data.txhistory.list.chain.*
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.txhistory.list.HistoryTxListManager
import com.tangem.domain.txhistory.list.HistoryTxListManager.*
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class DefaultHistoryTxListManager @AssistedInject constructor(
dispatchers: CoroutineDispatcherProvider,
private val expressServiceFetcher: ExpressServiceFetcher,
private val paymentAccountCurrency: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val bsdkOnChainHistoryFactory: BsdkOnChainHistory.Factory,
private val tangemPayOnChainHistoryFactory: TangemPayOnChainHistory.Factory,
private val indexTableOnChainHistoryFactory: IndexTableOnChainHistory.Factory,
@Assisted private val userWalletId: UserWalletId,
@Assisted private val currency: CryptoCurrency,
@Assisted private val modelScope: CoroutineScope,
) : HistoryTxListManager {
private val actionsFlow: Channel<Action> = Channel()
private val _historySources: MutableSharedFlow<HistorySources> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val state: StateFlow<HistoryState>
override val historySources: Flow<HistorySources> get() = _historySources.distinctUntilChanged()
init {
state = buildPipeline()
.retry { error ->
logError(error)
true
}
.flowOn(dispatchers.io)
.stateIn(modelScope, SharingStarted.Eagerly, HistoryState.Loading)
}
override fun reload() {
actionsFlow.trySend(Action.Reload(shouldRefresh = true))
}
override fun loadMore() {
actionsFlow.trySend(Action.LoadMore)
}
private fun buildPipeline() = channelFlow {
val reloadInitialLoading = flow {
// initial load
emit(Unit)
// any action triggers a reload
emitAll(actionsFlow.receiveAsFlow().map { })
}
val historySources = reloadInitialLoading
.onEach { channel.send(HistoryState.Loading) }
.mapNotNull {
val result = runSuspendCatching { loadSources() }.getOrNull()
// Error state, wait for any action to reload
if (result == null) channel.send(HistoryState.Error)
result
}
.onEach { _historySources.tryEmit(it) }
.first()
if (!isHistoryAvailable(historySources)) {
channel.send(HistoryState.Unavailable)
return@channelFlow
}
val env = HistoryEnvironment(
userWalletId = userWalletId,
currency = currency,
modelScope = modelScope,
)
val onChainHistory: OnChainHistory = when (historySources.onChainSource) {
OnChainSource.BSDK -> bsdkOnChainHistoryFactory.create(env)
OnChainSource.TangemPay -> tangemPayOnChainHistoryFactory.create(env)
OnChainSource.IndexTable -> indexTableOnChainHistoryFactory.create(env)
}
actionsFlow.receiveAsFlow()
.onEach { onChainHistory.sendAction(it) }
.launchIn(this)
onChainHistory.history().collect { channel.send(it) }
}
private suspend fun loadSources(): HistorySources = coroutineScope {
val onChainSource = async { resolveOnChainSource() }
val expressAsset = async { awaitExpressAsset() }
val asset = expressAsset.await()
HistorySources(
onChainSource = onChainSource.await(),
isExchangeAvailable = asset?.isExchangeAvailable == true,
isOnrampAvailable = asset?.isOnrampAvailable == true,
)
}
private fun isHistoryAvailable(sources: HistorySources): Boolean = with(sources) {
when (onChainSource) {
OnChainSource.BSDK -> true
OnChainSource.TangemPay -> true
OnChainSource.IndexTable -> isExchangeAvailable || isOnrampAvailable
}
}
private suspend fun resolveOnChainSource(): OnChainSource {
val isCryptoPortfolio = getAccountCryptoCurrencyStatusUseCase.invokeSync(userWalletId, currency).isSome()
return when {
isCryptoPortfolio -> txHistoryItemsCountUseCase(userWalletId, currency).fold(
ifLeft = { error ->
when (error) {
is TxHistoryStateError.DataError -> throw error
TxHistoryStateError.EmptyTxHistories -> OnChainSource.BSDK
TxHistoryStateError.TxHistoryNotImplemented -> OnChainSource.IndexTable
}
},
ifRight = { OnChainSource.BSDK },
)
paymentAccountCurrency.invokeSync(userWalletId, currency).isSome() -> OnChainSource.TangemPay
else -> OnChainSource.IndexTable
}
}
private suspend fun awaitExpressAsset(): ExpressAsset? {
val assetId = ExpressAsset.ID(currency)
return expressServiceFetcher.getOrFetch(userWalletId, assetId).getOrNull()
}
private fun logError(error: Throwable) {
val message = error.message.orEmpty()
TangemLogger.withTag(TX_HISTORY_TAG).e(message, error)
val event = ExceptionAnalyticsEvent(
exception = error,
params = mapOf("source" to TX_HISTORY_TAG),
)
analyticsExceptionHandler.sendException(event)
}
@AssistedFactory
interface Factory : HistoryTxListManager.Factory {
override fun create(
userWalletId: UserWalletId,
currency: CryptoCurrency,
modelScope: CoroutineScope,
): DefaultHistoryTxListManager
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.txhistory.list
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash
/**
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
* timestamp-DESC timeline.
*
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
* - matched enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
* of that hash are collapsed into this row (not emitted standalone).
* - unmatched standalone row (status shown, no on-chain leg). Both in-progress and terminal
* (finished/failed) express ops are shown so the user always sees their deals.
*
* On-chain transactions that no express op claimed pass through as [OnChainTx].
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
*/
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
val onChainByHash = onChain.associateBy { it.txHash }
val matchedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>()
express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get)
if (matched != null) {
result += op.withMatchedOnChain(OnChainTx.BSDK(matched))
matchedHashes += matched.txHash
} else {
result += op
}
}
onChain.forEach { tx ->
if (tx.txHash !in matchedHashes) {
result += OnChainTx.BSDK(tx)
}
}
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
}
/**
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
* express overlay. Same rules as [mergeTxHistoryInfos] matched express ops enrich (carry their
* on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through.
*
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
* express op's [ExpressTx.matchHash].
*
* WARNING: this hash-based match has NOT been validated against real TangemPay data yet TangemPay is
* not wired into the history end-to-end. Re-verify the hash semantics (which field carries the on-chain
* hash, and that it lines up with the express payin/payout hash) when TangemPay is integrated for real.
*/
internal fun mergeTangemPay(onChain: List<OnChainTx.TangemPay>, express: List<ExpressTx>): List<TxHistoryInfo> {
val onChainByHash = onChain.mapNotNull { tx -> tx.explorerHash?.let { it to tx } }.toMap()
val matchedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>()
express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get)
if (matched != null) {
result += op.withMatchedOnChain(matched)
matched.explorerHash?.let(matchedHashes::add)
} else {
result += op
}
}
onChain.forEach { tx ->
if (tx.explorerHash !in matchedHashes) {
result += tx
}
}
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
}
private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) {
is ExpressTx.Swap -> copy(txInfo = onChain)
is ExpressTx.Onramp -> copy(txInfo = onChain)
}

View file

@ -0,0 +1,138 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package com.tangem.data.txhistory.list.chain
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
import com.tangem.domain.txhistory.model.identityKey
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
private typealias HistoryTxBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
/** On-chain backbone backed by the blockchain SDK history, with the express overlay windowed to the loaded page. */
internal class BsdkOnChainHistory @AssistedInject constructor(
private val repository: TxHistoryRepositoryV2,
@Assisted private val env: HistoryEnvironment,
) : OnChainHistory {
private val userWalletId get() = env.userWalletId
private val currency get() = env.currency
override val actions: Channel<Action> = Channel()
override fun history(): Flow<HistoryState> {
val actionsFlow = flow {
// initial load
emit(Action.Reload(shouldRefresh = false))
// receive user actions
actions.receiveAsFlow().collect { emit(it) }
}
val batchFlow = repository.getTxHistoryBatchFlow(
context = TxHistoryListBatchingContext(
actionsFlow = actionsFlow.map { it.toBatchAction() },
coroutineScope = env.modelScope,
),
batchSize = BATCH_SIZE,
)
return batchFlow.state
.onEach { batchState -> autoLoadMoreUntilScrollable(batchState) }
.flatMapLatest { batchState -> buildState(batchState) }
}
/**
* Keeps requesting the next page while the loaded on-chain backbone is too short to make the list scrollable
* (fewer than [AUTO_LOAD_MORE_TARGET_COUNT] items) or the last page came back empty. Runs only in the
* [PaginationStatus.Paginating] state, so it stops as soon as the backbone reaches [PaginationStatus.EndOfPagination].
*/
private fun autoLoadMoreUntilScrollable(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
val status = batchState.status as? PaginationStatus.Paginating ?: return
val lastResult = status.lastResult as? BatchFetchResult.Success ?: return
val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size }
val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty
if (shouldLoadMore) {
sendAction(Action.LoadMore)
}
}
private fun buildState(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Flow<HistoryState> {
val mergedFlow: Flow<List<TxHistoryInfo>> = repository.getExpressHistory(
userWalletId = userWalletId,
currency = currency,
fromCreatedAtMillis = oldestLoadedTimestamp(batchState),
).map { express ->
val onChain = batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.distinctBy(TxInfo::identityKey)
.toList()
mergeTxHistoryInfos(onChain = onChain, express = express)
}
return when (batchState.status) {
PaginationStatus.None,
PaginationStatus.InitialLoading,
-> flowOf(HistoryState.Loading)
// Terminal initial failure: surface Error so the user can retry the on-chain page load.
is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error)
PaginationStatus.NextBatchLoading -> mergedFlow.map { merged ->
HistoryState.Content(merged, isLoadingMore = true, hasMore = true)
}
is PaginationStatus.Paginating<*> -> mergedFlow.map { merged ->
if (merged.isEmpty()) {
HistoryState.Empty
} else {
HistoryState.Content(merged, isLoadingMore = false, hasMore = true)
}
}
PaginationStatus.EndOfPagination -> mergedFlow.map { merged ->
if (merged.isEmpty()) {
HistoryState.Empty
} else {
HistoryState.Content(merged, isLoadingMore = false, hasMore = false)
}
}
}
}
private fun Action.toBatchAction(): HistoryTxBatchAction = when (this) {
is Action.Reload -> BatchAction.Reload(
TxHistoryListConfig(userWalletId, currency, shouldRefresh = shouldRefresh),
)
Action.LoadMore -> BatchAction.LoadMore(TxHistoryListConfig(userWalletId, currency, shouldRefresh = false))
}
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Long =
batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.minOfOrNull { it.timestampInMillis }
?: NO_LOWER_BOUND
private companion object {
const val BATCH_SIZE = 50
const val NO_LOWER_BOUND = 0L
/** Number of loaded items considered enough to make the list scrollable. */
const val AUTO_LOAD_MORE_TARGET_COUNT = 20
}
@AssistedFactory
interface Factory {
fun create(env: HistoryEnvironment): BsdkOnChainHistory
}
}

View file

@ -0,0 +1,73 @@
package com.tangem.data.txhistory.list.chain
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.repository.ExpressHistoryPage
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
/**
* Backbone for currencies without an on-chain history source: paginates the express (swap/onramp) overlay itself,
* using the unified history index. There is no on-chain leg, so every row is a standalone express item.
*/
internal class IndexTableOnChainHistory @AssistedInject constructor(
private val repository: TxHistoryRepositoryV2,
@Assisted private val env: HistoryEnvironment,
) : OnChainHistory {
override val actions: Channel<Action> = Channel()
override fun history(): Flow<HistoryState> = channelFlow {
val limit = MutableStateFlow(PAGE_SIZE)
actions.receiveAsFlow()
.onEach { action ->
when (action) {
is Action.Reload -> limit.value = PAGE_SIZE
Action.LoadMore -> limit.update { it + PAGE_SIZE }
}
}
.launchIn(this)
limit
.flatMapLatest { pageLimit ->
repository.getIndexedExpressHistory(
env.userWalletId,
env.currency,
pageLimit,
)
}
.map { page -> buildState(page) }
.collect { send(it) }
}
private fun buildState(page: ExpressHistoryPage): HistoryState {
val merged = mergeTxHistoryInfos(onChain = emptyList(), express = page.items)
return if (merged.isEmpty()) {
HistoryState.Empty
} else {
HistoryState.Content(merged, isLoadingMore = false, hasMore = page.hasMore)
}
}
private companion object {
const val PAGE_SIZE = 50
}
@AssistedFactory
interface Factory {
fun create(env: HistoryEnvironment): IndexTableOnChainHistory
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.txhistory.list.chain
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.pagination.BatchAction
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
/** One history backbone (BSDK / TangemPay / index-backed express): consumes [Action]s and emits [HistoryState]. */
internal interface OnChainHistory {
val actions: Channel<Action>
fun sendAction(action: Action) = actions.trySend(action)
fun history(): Flow<HistoryState>
}
/** Neutral external action, decoupled from the pagination [BatchAction]. */
internal sealed interface Action {
data class Reload(val shouldRefresh: Boolean) : Action
data object LoadMore : Action
}

View file

@ -0,0 +1,118 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package com.tangem.data.txhistory.list.chain
import com.tangem.data.txhistory.list.mergeTangemPay
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
private typealias TangemPayTxBatchAction = BatchAction<Int, TangemPayTxHistoryListConfig, Nothing>
/** On-chain backbone backed by the TangemPay tx history, with the express overlay windowed to the loaded page. */
internal class TangemPayOnChainHistory @AssistedInject constructor(
private val repository: TangemPayTxHistoryRepository,
private val txHistoryRepository: TxHistoryRepositoryV2,
@Assisted private val env: HistoryEnvironment,
) : OnChainHistory {
private val userWalletId get() = env.userWalletId
private val currency get() = env.currency
override val actions: Channel<Action> = Channel()
override fun history(): Flow<HistoryState> {
val actionsFlow = flow {
// initial load
emit(Action.Reload(shouldRefresh = false))
// receive user actions
actions.receiveAsFlow().collect { emit(it) }
}
val batchFlow = repository.getTxHistoryBatchFlow(
userWalletId = userWalletId,
batchSize = BATCH_SIZE,
context = TangemPayTxHistoryListBatchingContext(
actionsFlow = actionsFlow.map { it.toBatchAction() },
coroutineScope = env.modelScope,
),
)
return batchFlow.state
.flatMapLatest { batchState -> buildState(batchState) }
}
private fun buildState(batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>): Flow<HistoryState> {
val mergedFlow: Flow<List<TxHistoryInfo>> = txHistoryRepository.getExpressHistory(
userWalletId = userWalletId,
currency = currency,
fromCreatedAtMillis = oldestLoadedTimestamp(batchState),
).map { express ->
val onChain = batchState.data.asSequence()
.flatMap { it.data.asSequence() }
.map(OnChainTx::TangemPay)
.toList()
mergeTangemPay(onChain = onChain, express = express)
}
return when (batchState.status) {
PaginationStatus.None,
PaginationStatus.InitialLoading,
-> flowOf(HistoryState.Loading)
// Terminal initial failure: surface Error so the user can retry the on-chain page load.
is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error)
PaginationStatus.NextBatchLoading -> mergedFlow.map { merged ->
HistoryState.Content(merged, isLoadingMore = true, hasMore = true)
}
is PaginationStatus.Paginating<*> -> mergedFlow.map { merged ->
if (merged.isEmpty()) {
HistoryState.Empty
} else {
HistoryState.Content(merged, isLoadingMore = false, hasMore = true)
}
}
PaginationStatus.EndOfPagination -> mergedFlow.map { merged ->
if (merged.isEmpty()) {
HistoryState.Empty
} else {
HistoryState.Content(merged, isLoadingMore = false, hasMore = false)
}
}
}
}
private fun Action.toBatchAction(): TangemPayTxBatchAction = when (this) {
is Action.Reload -> BatchAction.Reload(TangemPayTxHistoryListConfig(shouldRefresh = shouldRefresh))
Action.LoadMore -> BatchAction.LoadMore(requestParams = null)
}
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>): Long =
batchState.data.asSequence()
.flatMap { it.data.asSequence() }
.minOfOrNull { it.date.millis }
?: NO_LOWER_BOUND
private companion object {
const val BATCH_SIZE = 50
const val NO_LOWER_BOUND = 0L
}
@AssistedFactory
interface Factory {
fun create(env: HistoryEnvironment): TangemPayOnChainHistory
}
}

View file

@ -0,0 +1,354 @@
package com.tangem.data.txhistory.list
import arrow.core.Either
import arrow.core.none
import arrow.core.right
import arrow.core.some
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.txhistory.list.chain.Action
import com.tangem.data.txhistory.list.chain.BsdkOnChainHistory
import com.tangem.data.txhistory.list.chain.IndexTableOnChainHistory
import com.tangem.data.txhistory.list.chain.TangemPayOnChainHistory
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistorySources
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.list.HistoryTxListManager.OnChainSource
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
* Verifies the orchestration in [DefaultHistoryTxListManager]: which on-chain backbone is chosen, availability gating,
* the universal state sequence (initial load error retry content), analytics on unexpected failures, and
* forwarding of external actions to the chosen backbone. The backbones themselves are stubbed (tested separately).
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultHistoryTxListManagerTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
private val expressServiceFetcher = mockk<ExpressServiceFetcher>()
private val paymentAccountCurrency = mockk<GetPaymentAccountCryptoCurrencyStatusUseCase>()
private val getAccountCurrencyStatusUseCase = mockk<GetAccountCurrencyStatusUseCase>()
private val txHistoryItemsCountUseCase = mockk<GetTxHistoryItemsCountUseCase>()
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>(relaxed = true)
private val bsdk = mockk<BsdkOnChainHistory>(relaxed = true)
private val tangemPay = mockk<TangemPayOnChainHistory>(relaxed = true)
private val indexTable = mockk<IndexTableOnChainHistory>(relaxed = true)
private val bsdkFactory = mockk<BsdkOnChainHistory.Factory>()
private val tangemPayFactory = mockk<TangemPayOnChainHistory.Factory>()
private val indexTableFactory = mockk<IndexTableOnChainHistory.Factory>()
@BeforeEach
fun setup() {
clearMocks(
expressServiceFetcher,
paymentAccountCurrency,
getAccountCurrencyStatusUseCase,
txHistoryItemsCountUseCase,
analyticsExceptionHandler,
bsdk,
tangemPay,
indexTable,
bsdkFactory,
tangemPayFactory,
indexTableFactory,
)
every { bsdkFactory.create(any()) } returns bsdk
every { tangemPayFactory.create(any()) } returns tangemPay
every { indexTableFactory.create(any()) } returns indexTable
every { bsdk.history() } returns emptyFlow()
every { tangemPay.history() } returns emptyFlow()
every { indexTable.history() } returns emptyFlow()
// Defaults: not a crypto portfolio, not a payment account, express available.
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns none()
coEvery { paymentAccountCurrency.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns none()
coEvery { txHistoryItemsCountUseCase(any(), any()) } returns 5.right()
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right()
}
// region source resolution
@ParameterizedTest
@ProvideTestModels
fun resolveOnChainSource(model: ResolutionModel) = runTest {
// Arrange
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
if (model.isCryptoPortfolio) mockk<AccountCryptoCurrencyStatus>().some() else none()
model.itemsCount?.let { coEvery { txHistoryItemsCountUseCase(any(), any()) } returns it }
coEvery { paymentAccountCurrency.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
if (model.isPayment) (mockk<Account.Payment>() to mockk<CryptoCurrencyStatus>()).some() else none()
// Act
val scope = runManager()
// Assert
verify(exactly = if (model.expected == OnChainSource.BSDK) 1 else 0) { bsdkFactory.create(any()) }
verify(exactly = if (model.expected == OnChainSource.TangemPay) 1 else 0) { tangemPayFactory.create(any()) }
verify(exactly = if (model.expected == OnChainSource.IndexTable) 1 else 0) { indexTableFactory.create(any()) }
scope.cancel()
}
private fun provideTestModels() = listOf(
ResolutionModel(
name = "crypto portfolio with tx history",
isCryptoPortfolio = true,
itemsCount = 5.right(),
expected = OnChainSource.BSDK,
),
ResolutionModel(
name = "crypto portfolio with empty tx history",
isCryptoPortfolio = true,
itemsCount = Either.Left(TxHistoryStateError.EmptyTxHistories),
expected = OnChainSource.BSDK,
),
ResolutionModel(
name = "crypto portfolio without tx history support",
isCryptoPortfolio = true,
itemsCount = Either.Left(TxHistoryStateError.TxHistoryNotImplemented),
expected = OnChainSource.IndexTable,
),
ResolutionModel(
name = "payment account",
isCryptoPortfolio = false,
isPayment = true,
expected = OnChainSource.TangemPay,
),
ResolutionModel(
name = "neither crypto nor payment",
isCryptoPortfolio = false,
isPayment = false,
expected = OnChainSource.IndexTable,
),
)
// endregion
// region availability
@Test
fun `GIVEN index-table source and express unavailable WHEN loading THEN Unavailable`() = runTest {
// Arrange: no on-chain source and express not available → nothing to show.
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = false, onramp = false).right()
// Act
val states: List<HistoryState>
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val manager = createManager(scope)
states = getEmittedValues(manager.state)
advanceUntilIdle()
// Assert
assertThat(states.last()).isEqualTo(HistoryState.Unavailable)
verify(exactly = 0) { indexTableFactory.create(any()) }
scope.cancel()
}
// endregion
// region state sequence
@Test
fun `GIVEN backbone emits content WHEN loading THEN Loading then Content`() = runTest {
// Arrange: BSDK source that emits a single content page.
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
mockk<AccountCryptoCurrencyStatus>().some()
every { bsdk.history() } returns flowOf(content())
// Act
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val states = getEmittedValues(createManager(scope).state)
advanceUntilIdle()
// Assert
assertThat(states.first()).isEqualTo(HistoryState.Loading)
assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java)
scope.cancel()
}
@Test
fun `GIVEN load fails WHEN retried from UI THEN Error then Loading then Content`() = runTest {
// Arrange: first load throws (DataError), retry succeeds with a content page.
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
mockk<AccountCryptoCurrencyStatus>().some()
coEvery { txHistoryItemsCountUseCase(any(), any()) } returnsMany listOf(
Either.Left(TxHistoryStateError.DataError(RuntimeException("boom"))),
5.right(),
)
every { bsdk.history() } returns flowOf(content())
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val manager = createManager(scope)
val states = getEmittedValues(manager.state)
advanceUntilIdle()
// Act: retry from the UI layer after the full-screen error.
manager.reload()
advanceUntilIdle()
// Assert
assertThat(states).containsExactly(
HistoryState.Loading,
HistoryState.Error,
HistoryState.Loading,
content(),
).inOrder()
scope.cancel()
}
@Test
fun `GIVEN backbone throws unexpectedly WHEN collecting THEN exception reported and pipeline recovers`() = runTest {
// Arrange: BSDK source; the history flow throws once, then recovers on the retry.
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
mockk<AccountCryptoCurrencyStatus>().some()
every { bsdk.history() } returnsMany listOf(
flow { throw RuntimeException("stream boom") },
flowOf(content()),
)
// Act
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val states = getEmittedValues(createManager(scope).state)
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsExceptionHandler.sendException(any()) }
assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java)
scope.cancel()
}
// endregion
// region historySources & action forwarding
@Test
fun `GIVEN successful load WHEN observed THEN historySources emitted`() = runTest {
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
mockk<AccountCryptoCurrencyStatus>().some()
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right()
every { bsdk.history() } returns MutableStateFlow(content())
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val sources = getEmittedValues(createManager(scope).historySources)
advanceUntilIdle()
assertThat(sources).containsExactly(
HistorySources(onChainSource = OnChainSource.BSDK, isExchangeAvailable = true, isOnrampAvailable = false),
)
scope.cancel()
}
@Test
fun `GIVEN loaded backbone WHEN reload and loadMore THEN forwarded to the backbone`() = runTest {
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
mockk<AccountCryptoCurrencyStatus>().some()
// Keep the backbone open so the forwarding collector stays active.
every { bsdk.history() } returns MutableStateFlow(content())
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val manager = createManager(scope)
getEmittedValues(manager.state)
advanceUntilIdle()
// Act — the actions channel is rendezvous, so let each action be consumed before sending the next.
manager.reload()
advanceUntilIdle()
manager.loadMore()
advanceUntilIdle()
// Assert
verify { bsdk.sendAction(Action.Reload(shouldRefresh = true)) }
verify { bsdk.sendAction(Action.LoadMore) }
scope.cancel()
}
// endregion
private fun TestScope.runManager(): CoroutineScope {
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
val manager = createManager(scope)
getEmittedValues(manager.state)
advanceUntilIdle()
return scope
}
private fun TestScope.createManager(modelScope: CoroutineScope) = DefaultHistoryTxListManager(
dispatchers = testDispatchers(),
expressServiceFetcher = expressServiceFetcher,
paymentAccountCurrency = paymentAccountCurrency,
getAccountCryptoCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
txHistoryItemsCountUseCase = txHistoryItemsCountUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
bsdkOnChainHistoryFactory = bsdkFactory,
tangemPayOnChainHistoryFactory = tangemPayFactory,
indexTableOnChainHistoryFactory = indexTableFactory,
userWalletId = userWalletId,
currency = currency,
modelScope = modelScope,
)
private fun TestScope.testDispatchers(): CoroutineDispatcherProvider {
val dispatcher: CoroutineDispatcher = StandardTestDispatcher(testScheduler)
return object : CoroutineDispatcherProvider {
override val main = dispatcher
override val mainImmediate = dispatcher
override val io = dispatcher
override val default = dispatcher
override val single = dispatcher
}
}
private fun content() = HistoryState.Content(items = emptyList(), isLoadingMore = false, hasMore = false)
private fun asset(exchange: Boolean, onramp: Boolean) = ExpressAsset(
id = ExpressAsset.ID(networkId = "eth", contractAddress = "0"),
isExchangeAvailable = exchange,
isOnrampAvailable = onramp,
)
internal data class ResolutionModel(
val name: String,
val isCryptoPortfolio: Boolean,
val itemsCount: Either<TxHistoryStateError, Int>? = null,
val isPayment: Boolean = false,
val expected: OnChainSource,
) {
override fun toString(): String = name
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.txhistory.utils
package com.tangem.data.txhistory.list
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExchangeTransaction

View file

@ -0,0 +1,250 @@
package com.tangem.data.txhistory.list.chain
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.models.Page
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.test.core.getEmittedValues
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Verifies the BSDK backbone: the pagination status maps to [HistoryState], the express overlay is merged in, and
* [BsdkOnChainHistory.autoLoadMoreUntilScrollable] dispatches a `LoadMore` while the loaded list is too short to
* scroll (fewer than the target) or the last page was empty, and stops otherwise.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class BsdkOnChainHistoryTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
private val repository = mockk<TxHistoryRepositoryV2>()
// region state mapping
@Test
fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest {
val states = collect(state(items = 0, status = PaginationStatus.InitialLoading))
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Loading)
}
@Test
fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest {
val states = collect(state(items = 0, status = PaginationStatus.InitialLoadingError(RuntimeException("boom"))))
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Error)
}
@Test
fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest {
val states = collect(state(items = 0, status = PaginationStatus.EndOfPagination))
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Empty)
}
@Test
fun `GIVEN items and more to load WHEN loading THEN Content with hasMore true`() = runTest {
val states = collect(state(items = 25, status = paginating(empty = false)))
advanceUntilIdle()
val last = states.last()
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
with(last as HistoryState.Content) {
assertThat(items).hasSize(25)
assertThat(hasMore).isTrue()
assertThat(isLoadingMore).isFalse()
}
}
@Test
fun `GIVEN items and end reached WHEN loading THEN Content with hasMore false`() = runTest {
val states = collect(state(items = 25, status = PaginationStatus.EndOfPagination))
advanceUntilIdle()
assertThat((states.last() as HistoryState.Content).hasMore).isFalse()
}
@Test
fun `GIVEN express op matched to a loaded on-chain tx WHEN loading THEN row is enriched`() = runTest {
val states = collect(
batchState = state(items = 25, status = PaginationStatus.EndOfPagination, firstTxHash = "match"),
express = listOf(createSwap(matchHash = "match")),
)
advanceUntilIdle()
val enriched = (states.last() as HistoryState.Content).items.filterIsInstance<ExpressTx.Swap>().single()
assertThat(enriched.txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
}
// endregion
// region auto-load
@Test
fun `GIVEN a short page with more to load WHEN paginating THEN a LoadMore is dispatched`() = runTest {
val actions = collectDispatchedActions(state(items = 10, status = paginating(empty = false)))
advanceUntilIdle()
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isNotEmpty()
}
@Test
fun `GIVEN a page long enough to scroll WHEN paginating THEN no LoadMore is dispatched`() = runTest {
val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = false)))
advanceUntilIdle()
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isEmpty()
}
@Test
fun `GIVEN the last page came back empty WHEN paginating THEN a LoadMore is dispatched`() = runTest {
// Enough items to be scrollable, but the last fetch was empty (a gap) → keep bridging to the end.
val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = true)))
advanceUntilIdle()
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isNotEmpty()
}
// endregion
private fun TestScope.collect(
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
express: List<ExpressTx> = emptyList(),
): List<HistoryState> {
stubRepository(batchState, express)
return getEmittedValues(createSut().history())
}
/**
* Collects the [BatchAction]s the SUT dispatches to the pagination source, to assert the auto-load decision.
*
* The actions channel is rendezvous, so the source-side collector must be parked before the auto-load check
* fires its `LoadMore`: we start collecting the captured `actionsFlow` (and let it park past the initial `Reload`)
* before collecting `history()`, which is what triggers the auto-load check.
*/
private fun TestScope.collectDispatchedActions(
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
): List<BatchAction<*, *, *>> {
val contextSlot = slot<TxHistoryListBatchingContext>()
stubRepository(batchState, express = emptyList(), contextSlot = contextSlot)
val historyFlow = createSut().history() // captures the context synchronously
val actions = getEmittedValues(contextSlot.captured.actionsFlow)
advanceUntilIdle() // the source-side collector emits the initial Reload, then parks
getEmittedValues(historyFlow) // triggers the auto-load check, which may dispatch a LoadMore
return actions
}
private fun stubRepository(
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
express: List<ExpressTx>,
contextSlot: io.mockk.CapturingSlot<TxHistoryListBatchingContext>? = null,
) {
val batchFlow = mockk<TxHistoryListBatchFlow> { every { state } returns MutableStateFlow(batchState) }
if (contextSlot != null) {
every { repository.getTxHistoryBatchFlow(any(), capture(contextSlot)) } returns batchFlow
} else {
every { repository.getTxHistoryBatchFlow(any(), any()) } returns batchFlow
}
every { repository.getExpressHistory(any(), any(), any()) } returns flowOf(express)
}
private fun TestScope.createSut() = BsdkOnChainHistory(
repository = repository,
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
)
private fun state(
items: Int,
status: PaginationStatus<PaginationWrapper<TxInfo>>,
firstTxHash: String? = null,
): BatchListState<Int, PaginationWrapper<TxInfo>> {
val wrapper = PaginationWrapper(
currentPage = Page.Initial,
nextPage = Page.LastPage,
items = List(items) { index ->
val hash = if (index == 0 && firstTxHash != null) firstTxHash else "hash-$index"
createTxInfo(txHash = hash)
},
)
return BatchListState(
data = if (items == 0) emptyList() else listOf(Batch(key = 0, data = wrapper)),
status = status,
)
}
private fun paginating(empty: Boolean): PaginationStatus<PaginationWrapper<TxInfo>> {
val wrapper = PaginationWrapper<TxInfo>(Page.Initial, Page.LastPage, items = emptyList())
return PaginationStatus.Paginating(BatchFetchResult.Success(data = wrapper, empty = empty, last = false))
}
private fun createTxInfo(txHash: String) = TxInfo(
txHash = txHash,
timestampInMillis = 100,
isOutgoing = true,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
sourceType = TxInfo.SourceType.Single("addr"),
interactionAddressType = null,
status = TxInfo.TransactionStatus.Confirmed,
type = TxInfo.TransactionType.Transfer,
amount = BigDecimal.ONE,
)
private fun createSwap(matchHash: String) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = ExpressExchangeStatus.Finished,
createdAtMillis = 100,
provider = null,
payinHash = matchHash,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = BigDecimal.ONE,
decimals = 8,
),
),
isOutgoing = true,
txInfo = null,
)
}

View file

@ -0,0 +1,142 @@
package com.tangem.data.txhistory.list.chain
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.repository.ExpressHistoryPage
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.test.core.getEmittedValues
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
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
/**
* Verifies the index-backed express backbone (used when a currency has no on-chain history source): it maps an
* [ExpressHistoryPage] to [HistoryState] and grows/reset the paging window on `LoadMore` / `Reload`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class IndexTableOnChainHistoryTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
private val repository = mockk<TxHistoryRepositoryV2>()
@BeforeEach
fun setup() {
clearMocks(repository)
}
@Test
fun `GIVEN empty page WHEN loading THEN Empty`() = runTest {
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
flowOf(ExpressHistoryPage(items = emptyList(), hasMore = false))
val states = getEmittedValues(createSut().history())
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Empty)
}
@Test
fun `GIVEN non-empty page with more WHEN loading THEN Content with hasMore true`() = runTest {
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
val states = getEmittedValues(createSut().history())
advanceUntilIdle()
val last = states.last()
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
with(last as HistoryState.Content) {
assertThat(items).hasSize(1)
assertThat(hasMore).isTrue()
assertThat(isLoadingMore).isFalse()
}
}
@Test
fun `GIVEN loaded page WHEN loadMore THEN window grows by page size`() = runTest {
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
val sut = createSut()
getEmittedValues(sut.history())
advanceUntilIdle()
sut.sendAction(Action.LoadMore)
advanceUntilIdle()
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) }
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE * 2) }
}
@Test
fun `GIVEN grown window WHEN reload THEN window resets to page size`() = runTest {
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
val sut = createSut()
getEmittedValues(sut.history())
advanceUntilIdle()
sut.sendAction(Action.LoadMore)
advanceUntilIdle()
clearMocks(repository, answers = false)
sut.sendAction(Action.Reload(shouldRefresh = true))
advanceUntilIdle()
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) }
}
private fun TestScope.createSut() = IndexTableOnChainHistory(
repository = repository,
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
)
private fun createSwap() = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = ExpressExchangeStatus.Waiting,
createdAtMillis = 100,
provider = null,
payinHash = null,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = BigDecimal.ONE,
decimals = 8,
),
),
isOutgoing = true,
txInfo = null,
)
private companion object {
const val PAGE_SIZE = 50
}
}

View file

@ -0,0 +1,177 @@
package com.tangem.data.txhistory.list.chain
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.test.core.getEmittedValues
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.Currency
/**
* Verifies the TangemPay backbone: items map to [OnChainTx.TangemPay], the pagination status maps to [HistoryState],
* and the express overlay is merged in by hash.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayOnChainHistoryTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
private val repository = mockk<TangemPayTxHistoryRepository>()
private val txHistoryRepository = mockk<TxHistoryRepositoryV2>()
@Test
fun `GIVEN loaded items and end WHEN loading THEN Content of TangemPay rows`() = runTest {
val payment = createPayment(id = "p1", transactionHash = "h1")
val states = collect(
batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination),
express = emptyList(),
)
advanceUntilIdle()
val last = states.last()
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
with(last as HistoryState.Content) {
assertThat(hasMore).isFalse()
assertThat(items.single()).isInstanceOf(OnChainTx.TangemPay::class.java)
}
}
@Test
fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest {
val states = collect(
batchState = batchListState(items = emptyList(), status = PaginationStatus.InitialLoading),
express = emptyList(),
)
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Loading)
}
@Test
fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest {
val states = collect(
batchState = batchListState(
items = emptyList(),
status = PaginationStatus.InitialLoadingError(throwable = RuntimeException("boom")),
),
express = emptyList(),
)
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Error)
}
@Test
fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest {
val states = collect(
batchState = batchListState(items = emptyList(), status = PaginationStatus.EndOfPagination),
express = emptyList(),
)
advanceUntilIdle()
assertThat(states.last()).isEqualTo(HistoryState.Empty)
}
@Test
fun `GIVEN express op matching a TangemPay hash WHEN loading THEN row is enriched`() = runTest {
val payment = createPayment(id = "p1", transactionHash = "match")
val express = listOf(createSwap(matchHash = "match"))
val states = collect(
batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination),
express = express,
)
advanceUntilIdle()
val items = (states.last() as HistoryState.Content).items
val enriched = items.filterIsInstance<ExpressTx.Swap>().single()
assertThat(enriched.txInfo).isInstanceOf(OnChainTx.TangemPay::class.java)
}
private fun TestScope.collect(
batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>,
express: List<ExpressTx>,
): List<HistoryState> {
val batchFlow = mockk<TangemPayTxHistoryListBatchFlow> {
every { state } returns MutableStateFlow(batchState)
}
every { repository.getTxHistoryBatchFlow(any(), any(), any()) } returns batchFlow
every { txHistoryRepository.getExpressHistory(any(), any(), any()) } returns flowOf(express)
val sut = TangemPayOnChainHistory(
repository = repository,
txHistoryRepository = txHistoryRepository,
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
)
return getEmittedValues(sut.history())
}
private fun batchListState(
items: List<TangemPayTxHistoryItem>,
status: PaginationStatus<List<TangemPayTxHistoryItem>>,
) = BatchListState(
data = if (items.isEmpty()) emptyList() else listOf(Batch(key = 0, data = items)),
status = status,
)
private fun createPayment(id: String, transactionHash: String) = TangemPayTxHistoryItem.Payment(
id = id,
jsonRepresentation = "",
date = DateTime(100L),
amount = BigDecimal.ONE,
currency = Currency.getInstance("USD"),
transactionHash = transactionHash,
)
private fun createSwap(matchHash: String) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = ExpressExchangeStatus.Finished,
createdAtMillis = 100,
provider = null,
payinHash = matchHash,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = BigDecimal.ONE,
decimals = 8,
),
),
isOutgoing = true,
txInfo = null,
)
}

View file

@ -27,5 +27,6 @@ dependencies {
api(projects.domain.express.models)
api(projects.domain.models)
api(projects.domain.txhistory.models)
api(projects.domain.visa.models)
// endregion
}

View file

@ -0,0 +1,74 @@
package com.tangem.domain.txhistory.list
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
import com.tangem.domain.txhistory.model.TxHistoryInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
/**
* Reads the unified transaction history for a currency: the on-chain backbone (BSDK / TangemPay / index-backed
* (wallet, currency) via [Factory].
*/
interface HistoryTxListManager {
/** The whole history state, newest first — the single source of truth for the UI. */
val state: StateFlow<HistoryState>
val historySources: Flow<HistorySources>
fun reload()
fun loadMore()
/** Universal history state: the caller renders exactly one of these. */
sealed interface HistoryState {
data object Loading : HistoryState
data object Unavailable : HistoryState
data object Empty : HistoryState
data object Error : HistoryState
data class Content(
val items: List<TxHistoryInfo>,
val isLoadingMore: Boolean,
val hasMore: Boolean,
) : HistoryState
}
/** How the history starts for a currency: which on-chain backbone exists and whether express is available. */
data class HistorySources(
val onChainSource: OnChainSource,
val isExchangeAvailable: Boolean,
val isOnrampAvailable: Boolean,
)
data class HistoryEnvironment(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val modelScope: CoroutineScope,
)
enum class OnChainSource { BSDK, TangemPay, IndexTable }
interface Factory {
fun create(
userWalletId: UserWalletId,
currency: CryptoCurrency,
modelScope: CoroutineScope,
): HistoryTxListManager
}
}
/**
* Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet.
*
* Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from
* the live merged list as its status changes. The seed also covers rows not present in [state] yet (e.g. a pending
* tx surfaced from the currency status), which would otherwise never resolve.
*/
fun HistoryTxListManager.txHistoryInfoFlow(item: TxHistoryInfo): Flow<TxHistoryInfo> = state
.mapNotNull { (it as? HistoryState.Content)?.items }
.mapNotNull { list -> list.firstOrNull { it.txId == item.txId } }
.onStart { emit(item) }
.distinctUntilChanged()

View file

@ -4,6 +4,7 @@ import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
/**
* A single row of the unified transaction history shown on the token-details screen.
@ -38,11 +39,10 @@ sealed interface OnChainTx : TxHistoryInfo {
override val timestampMillis: Long get() = txInfo.timestampInMillis
}
// todo txHistory next step
/*data class TangemPay(val txInfo: TangemPayTxHistoryItem) : OnChainTx {
data class TangemPay(val txInfo: TangemPayTxHistoryItem) : OnChainTx {
override val txId: String get() = txInfo.id
override val timestampMillis: Long get() = txInfo.date.millis
}*/
}
// todo txHistory next step
/*data class Gateway() : OnChainTx {
@ -71,6 +71,13 @@ fun TxInfo.identityKey(): String = "$txHash|$type"
inline val TxHistoryInfo.explorerHash: String?
get() = when (this) {
is OnChainTx.BSDK -> txInfo.txHash
is OnChainTx.TangemPay -> when (val item = txInfo) {
is TangemPayTxHistoryItem.Payment -> item.transactionHash
is TangemPayTxHistoryItem.Collateral -> item.transactionHash
is TangemPayTxHistoryItem.Spend,
is TangemPayTxHistoryItem.Fee,
-> null
}
is ExpressTx -> matchHash
}
@ -78,6 +85,7 @@ inline val TxHistoryInfo.explorerHash: String?
inline val TxHistoryInfo.idToCopy: String
get() = when (this) {
is OnChainTx.BSDK -> txInfo.txHash
is OnChainTx.TangemPay -> explorerHash ?: txId
is ExpressTx -> txId
}

View file

@ -32,5 +32,7 @@ internal class TxHistoryInfoToTransactionItemUMConverter(
is TransactionItemUM.Pill -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) })
else -> um
}
// todo txHistory: render standalone TangemPay on-chain rows when TangemPay is wired into the history
is OnChainTx.TangemPay -> TODO("TangemPay on-chain row rendering is not implemented yet")
}
}

View file

@ -51,6 +51,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) {
is OnChainTx.BSDK -> onChainConverter.convert(value.txInfo)
// todo txHistory: build the details card for standalone TangemPay rows when TangemPay is wired in
is OnChainTx.TangemPay -> TODO("TangemPay on-chain details rendering is not implemented yet")
is ExpressTx -> expressConverter.convert(value)
}
}

View file

@ -15,6 +15,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.list.HistoryTxListManager
import com.tangem.domain.txhistory.list.txHistoryInfoFlow
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.domain.txhistory.models.TxHistoryStateError
@ -30,9 +32,7 @@ import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUpdateListener
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
import com.tangem.features.txhistory.state.TxHistoryStateController
import com.tangem.features.txhistory.utils.HistoryTxListManager
import com.tangem.features.txhistory.utils.TxHistoryListManager
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.features.txhistory.utils.*
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -99,6 +99,7 @@ internal class TxHistoryModel @Inject constructor(
historyTxListManagerFactory.create(
userWalletId = params.userWalletId,
currency = params.currency,
modelScope = modelScope,
)
} else {
null
@ -133,23 +134,34 @@ internal class TxHistoryModel @Inject constructor(
if (historyTxListManager != null) {
combine(
flow = historyTxListManager.items,
flow = historyTxListManager.state,
flow2 = lookupDataFlow,
transform = { merged, lookup -> merged to lookup },
transform = { state, lookup -> state to lookup },
)
.onEach { (merged, lookup) ->
stateController.setContent(
snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(merged, lookup)),
loadMore = ::loadMoreItems,
onExploreClick = ::openExplorer,
)
}
.onEach { (state, lookup) -> applyHistoryState(state, lookup) }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
}
historyTxListManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
.launchIn(modelScope)
private fun applyHistoryState(state: HistoryTxListManager.HistoryState, lookup: TxHistoryLookupContext) {
when (state) {
HistoryTxListManager.HistoryState.Loading ->
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Unavailable ->
stateController.setNotSupported(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Empty ->
stateController.setEmpty(onExploreClick = ::openExplorer)
HistoryTxListManager.HistoryState.Error ->
stateController.setError(onReloadClick = ::reload, onExploreClick = ::openExplorer)
is HistoryTxListManager.HistoryState.Content -> {
stateController.setContent(
snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(state.items, lookup)),
loadMore = ::loadMoreItems,
onExploreClick = ::openExplorer,
)
stateController.updateLoadingMore(isLoadingMore = state.isLoadingMore)
}
}
}
@ -192,19 +204,17 @@ internal class TxHistoryModel @Inject constructor(
private fun initListManager() {
modelScope.launch {
txHistoryListManager?.init()
historyTxListManager?.init()
}
}
private fun loadTxInfo() {
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight {
txHistoryListManager?.startLoading()
historyTxListManager?.startLoading()
}
txHistoryListManager?.let { legacy ->
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight { legacy.startLoading() }
}
}
if (txHistoryFeatureToggle.isNewTxHistoryEnabled) {
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(
@ -219,13 +229,13 @@ internal class TxHistoryModel @Inject constructor(
if (stateController.isNotSupported) return
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
historyTxListManager?.reload()
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight {
txHistoryListManager?.reload()
historyTxListManager?.reload()
}
txHistoryListManager?.let { legacy ->
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
.onRight { legacy.reload() }
}
if (txHistoryFeatureToggle.isNewTxHistoryEnabled) {
val trigger = TxHistoryFetchTrigger.TokenDetailsPTR(
walletId = params.userWalletId,
@ -245,9 +255,9 @@ internal class TxHistoryModel @Inject constructor(
}
private fun loadMoreItems(): Boolean {
historyTxListManager?.loadMore()
modelScope.launch {
txHistoryListManager?.loadMore(params.userWalletId, params.currency)
historyTxListManager?.loadMore(params.userWalletId, params.currency)
}
return true
}

View file

@ -1,153 +0,0 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.*
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
private typealias HistoryTxBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
/**
* Redesign-only transaction-history pipeline that merges the on-chain pagination backbone with the
* express (swap/onramp) overlay. Unlike [TxHistoryListManager] there is no legacy branch.
*
* The express overlay is asset-scoped and time-windowed to the oldest loaded on-chain timestamp
* (re-subscribed via [flatMapLatest] as more pages load) and re-emits live as the express DB updates,
* so status changes render without depending on the transaction count.
*/
@Suppress("LongParameterList")
internal class HistoryTxListManager @AssistedInject constructor(
private val repository: TxHistoryRepositoryV2,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWalletId: UserWalletId,
@Assisted private val currency: CryptoCurrency,
) {
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<HistoryTxBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<State> = MutableStateFlow(State())
val items: Flow<List<TxHistoryInfo>> = state
.filter { it.hasContent }
.map { it.items }
.distinctUntilChanged()
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
/**
* Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet.
*
* Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from
* the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending
* tx surfaced from the currency status), which would otherwise never resolve.
*/
fun txHistoryInfoFlow(item: TxHistoryInfo): Flow<TxHistoryInfo> = items
.mapNotNull { list -> list.firstOrNull { it.txId == item.txId } }
.onStart { emit(item) }
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun init() {
coroutineScope {
val batchFlow = repository.getTxHistoryBatchFlow(
context = TxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this),
batchSize = BATCH_SIZE,
)
val sharedBatchState = batchFlow.state.shareIn(scope = this, started = SharingStarted.Eagerly, replay = 1)
val expressFlow = sharedBatchState
.map(::oldestLoadedTimestamp)
.distinctUntilChanged()
.flatMapLatest { fromCreatedAtMillis ->
repository.getExpressHistory(userWalletId, currency, fromCreatedAtMillis)
}
// Let the merge run on the first on-chain emission before the express query resolves.
.onStart { emit(emptyList()) }
combine(sharedBatchState, expressFlow) { batchState, express ->
buildState(batchState, express)
}
.flowOn(dispatchers.default)
.onEach { state.value = it }
.launchIn(scope = this)
.saveIn(jobHolder)
}
}
suspend fun startLoading() {
actionsFlow.emit(
BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)),
)
}
suspend fun reload() {
actionsFlow.emit(
BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = true)),
)
}
suspend fun loadMore(userWalletId: UserWalletId, currency: CryptoCurrency) {
actionsFlow.emit(
BatchAction.LoadMore(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)),
)
}
private fun buildState(
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
express: List<ExpressTx>,
): State {
val onChain = batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.distinctBy(TxInfo::identityKey)
.toList()
val merged = mergeTxHistoryInfos(onChain = onChain, express = express)
return State(status = batchState.status, items = merged)
}
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Long =
batchState.data.asSequence()
.flatMap { it.data.items.asSequence() }
.minOfOrNull { it.timestampInMillis }
?: NO_LOWER_BOUND
private data class State(
val status: PaginationStatus<*> = PaginationStatus.None,
val items: List<TxHistoryInfo> = emptyList(),
) {
val hasContent: Boolean
get() = status !is PaginationStatus.None &&
status !is PaginationStatus.InitialLoading &&
status !is PaginationStatus.InitialLoadingError
}
private companion object {
const val BATCH_SIZE = 50
const val NO_LOWER_BOUND = 0L
}
@AssistedFactory
interface Factory {
fun create(userWalletId: UserWalletId, currency: CryptoCurrency): HistoryTxListManager
}
}

View file

@ -1,51 +0,0 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
/**
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
* timestamp-DESC timeline.
*
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
* - matched enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
* of that hash are collapsed into this row (not emitted standalone).
* - unmatched standalone row (status shown, no on-chain leg). Both in-progress and terminal
* (finished/failed) express ops are shown so the user always sees their deals.
*
* On-chain transactions that no express op claimed pass through as [OnChainTx].
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
*/
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
val onChainByHash = onChain.associateBy { it.txHash }
val matchedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>()
express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get)
if (matched != null) {
result += op.withMatchedTxInfo(matched)
matchedHashes += matched.txHash
} else {
result += op
}
}
onChain.forEach { tx ->
if (tx.txHash !in matchedHashes) {
result += OnChainTx.BSDK(tx)
}
}
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
}
private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx {
val matched = OnChainTx.BSDK(txInfo)
return when (this) {
is ExpressTx.Swap -> copy(txInfo = matched)
is ExpressTx.Onramp -> copy(txInfo = matched)
}
}