From 297304e6e717b104bb9981e0656ef2d295c6d3f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Jun 2026 16:36:54 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + .../txhistory/TxHistoryFeatureToggles.kt | 1 + ...HistoryInfoToTransactionItemUMConverter.kt | 27 ++++ .../di/DefaultTxHistoryFeatureToggles.kt | 3 + .../txhistory/di/TxHistoryFeatureModule.kt | 5 + .../txhistory/model/TxHistoryModel.kt | 150 +++++++++++++----- .../txhistory/utils/HistoryTxListManager.kt | 141 ++++++++++++++++ .../txhistory/utils/TxHistoryInfoMerger.kt | 97 +++++++++++ .../txhistory/utils/TxHistoryUiManager.kt | 12 +- .../utils/TxHistoryInfoMergerTest.kt | 142 +++++++++++++++++ 10 files changed, 534 insertions(+), 48 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 9fe78fd0ef..67130ab6f2 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -155,6 +155,10 @@ "name": "AND_15715_SWAP_BEST_DEX_RATE_ENABLED", "version": "undefined" }, + { + "name": "AND_15767_NEW_TX_HISTORY_ENABLED", + "version": "undefined" + }, { "name": "AND_14829_WARNINGS_REFACTORING_ENABLED", "version": "undefined" diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt index b56fa60857..64b805af2b 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/TxHistoryFeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.txhistory interface TxHistoryFeatureToggles { val isSolanaTxHistoryEnabled: Boolean + val isNewTxHistoryEnabled: Boolean } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt new file mode 100644 index 0000000000..574f6658c0 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -0,0 +1,27 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.features.txhistory.utils.toSyntheticTxInfo +import com.tangem.utils.converter.Converter + +/** + * Converts a merged [TxHistoryInfo] row to [TransactionItemUM], delegating to the on-chain + * [TxHistoryItemToTransactionItemUMConverter]: on-chain rows convert their `TxInfo` directly, express + * rows convert a synthesized `TxInfo` view (see [toSyntheticTxInfo]). + */ +internal class TxHistoryInfoToTransactionItemUMConverter( + private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter, +) : Converter { + + override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) { + is OnChainTx -> convertOnChain(value) + is ExpressTx -> txInfoConverter.convert(value.toSyntheticTxInfo()) + } + + private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { + is OnChainTx.BSDK -> txInfoConverter.convert(value.txInfo) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/DefaultTxHistoryFeatureToggles.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/DefaultTxHistoryFeatureToggles.kt index f20da20c71..96448a0f83 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/DefaultTxHistoryFeatureToggles.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/DefaultTxHistoryFeatureToggles.kt @@ -11,4 +11,7 @@ internal class DefaultTxHistoryFeatureToggles @Inject constructor( override val isSolanaTxHistoryEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.SOLANA_TX_HISTORY_ENABLED) + + override val isNewTxHistoryEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15767_NEW_TX_HISTORY_ENABLED) } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt index d2b0c4dcb4..bb46e57bfb 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt @@ -1,5 +1,6 @@ package com.tangem.features.txhistory.di +import com.tangem.features.txhistory.TxHistoryFeatureToggles import com.tangem.features.txhistory.component.DefaultTxHistoryComponent import com.tangem.features.txhistory.component.DefaultTxHistoryDetailsComponent import com.tangem.features.txhistory.component.TxHistoryComponent @@ -18,6 +19,10 @@ internal interface TxHistoryFeatureModule { @Singleton fun bindComponentFactory(factory: DefaultTxHistoryComponent.Factory): TxHistoryComponent.Factory + @Binds + @Singleton + fun bindTxHistoryFeatureToggle(impl: DefaultTxHistoryFeatureToggles): TxHistoryFeatureToggles + @Binds @Singleton fun bindTxHistoryDetailsComponentFactory( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index b40657fc23..76c93935a5 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier @@ -19,34 +20,32 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.features.txhistory.TxHistoryFeatureToggles import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter +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.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch import com.tangem.utils.logging.TangemLogger +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @Suppress("LongParameterList") @@ -64,6 +63,8 @@ internal class TxHistoryModel @Inject constructor( private val txHistoryUpdateListener: TxHistoryUpdateListener, private val stateController: TxHistoryStateController, private val designFeatureToggles: DesignFeatureToggles, + private val txHistoryFeatureToggle: TxHistoryFeatureToggles, + private val historyTxListManagerFactory: HistoryTxListManager.Factory, repository: TxHistoryRepositoryV2, paramsContainer: ParamsContainer, multiAccountStatusListSupplier: MultiAccountStatusListSupplier, @@ -101,16 +102,30 @@ internal class TxHistoryModel @Inject constructor( private val legacyTxHistoryItemConverter = TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this) - private val txHistoryListManager = TxHistoryListManager( - repository = repository, - dispatchers = dispatchers, - userWalletId = params.userWalletId, - currency = params.currency, - designFeatureToggles = designFeatureToggles, - txHistoryUiActions = this, - lookupDataFlow = lookupDataFlow, - legacyTxHistoryItemConverter = legacyTxHistoryItemConverter, - ) + + private val txHistoryListManager: TxHistoryListManager? = if (!txHistoryFeatureToggle.isNewTxHistoryEnabled) { + TxHistoryListManager( + repository = repository, + dispatchers = dispatchers, + userWalletId = params.userWalletId, + currency = params.currency, + designFeatureToggles = designFeatureToggles, + txHistoryUiActions = this, + lookupDataFlow = lookupDataFlow, + legacyTxHistoryItemConverter = legacyTxHistoryItemConverter, + ) + } else { + null + } + + private val historyTxListManager: HistoryTxListManager? = if (txHistoryFeatureToggle.isNewTxHistoryEnabled) { + historyTxListManagerFactory.create( + userWalletId = params.userWalletId, + currency = params.currency, + ) + } else { + null + } val legacyUiState = stateController.legacyUiState val uiState = stateController.uiState @@ -143,18 +158,65 @@ internal class TxHistoryModel @Inject constructor( } private fun subscribeToUiItemChanges() { - txHistoryListManager.uiItems - .onEach { snapshot -> - stateController.setContent( - snapshot = snapshot, - loadMore = ::loadMoreItems, - onExploreClick = ::openExplorer, - ) + txHistoryListManager + ?.uiItems + ?.onEach { snapshot -> stateController.setContent( + snapshot = snapshot, + loadMore = ::loadMoreItems, + onExploreClick = ::openExplorer, + ) } + ?.launchIn(modelScope) + txHistoryListManager + ?.paginationStatus + ?.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } + ?.launchIn(modelScope) + + if (historyTxListManager != null) { + combine( + flow = historyTxListManager.items, + flow2 = lookupDataFlow, + transform = { merged, lookup -> merged to lookup }, + ) + .onEach { (merged, lookup) -> + stateController.setContent( + snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(merged, lookup)), + loadMore = ::loadMoreItems, + onExploreClick = ::openExplorer, + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + + historyTxListManager.paginationStatus + .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } + .launchIn(modelScope) + } + } + + // Temporary: express rows are mapped to UI via a synthesized TxInfo (see ExpressTx.toSyntheticTxInfo). + private fun buildUiItems( + merged: List, + lookup: TxHistoryLookupContext, + ): ImmutableList { + val converter = TxHistoryInfoToTransactionItemUMConverter( + txInfoConverter = TxHistoryItemToTransactionItemUMConverter( + currency = params.currency, + txHistoryUiActions = this, + lookupContext = lookup, + ), + ) + + val items = mutableListOf() + var lastDate: String? = null + merged.forEach { tx -> + val date = tx.timestampMillis.toDateFormatWithTodayYesterday() + if (date != lastDate) { + items += TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(title = date, itemKey = "group-$date") + lastDate = date } - .launchIn(modelScope) - txHistoryListManager.paginationStatus - .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } - .launchIn(modelScope) + items += TxHistoryItemsUM.TxHistoryItemUM.Transaction(converter.convert(tx)) + } + return items.toImmutableList() } private fun subscribeToUpdateListener() { @@ -164,7 +226,10 @@ internal class TxHistoryModel @Inject constructor( } private fun initListManager() { - modelScope.launch { txHistoryListManager.init() } + modelScope.launch { + txHistoryListManager?.init() + historyTxListManager?.init() + } } private fun loadTxInfo() { @@ -172,7 +237,10 @@ internal class TxHistoryModel @Inject constructor( modelScope.launch { txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) .onLeft(::handleErrorState) - .onRight { txHistoryListManager.startLoading() } + .onRight { + txHistoryListManager?.startLoading() + historyTxListManager?.startLoading() + } } } @@ -183,7 +251,10 @@ internal class TxHistoryModel @Inject constructor( modelScope.launch { txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) .onLeft(::handleErrorState) - .onRight { txHistoryListManager.reload() } + .onRight { + txHistoryListManager?.reload() + historyTxListManager?.reload() + } } } @@ -196,7 +267,10 @@ internal class TxHistoryModel @Inject constructor( } private fun loadMoreItems(): Boolean { - modelScope.launch { txHistoryListManager.loadMore(params.userWalletId, params.currency) } + modelScope.launch { + txHistoryListManager?.loadMore(params.userWalletId, params.currency) + historyTxListManager?.loadMore(params.userWalletId, params.currency) + } return true } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt new file mode 100644 index 0000000000..cf23065cdc --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt @@ -0,0 +1,141 @@ +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 + +/** + * 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 = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val state: MutableStateFlow = MutableStateFlow(State()) + + val items: Flow> = state + .filter { it.hasContent } + .map { it.items } + .distinctUntilChanged() + + val paginationStatus: Flow> = state.map { it.status }.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>, + express: List, + ): 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>): 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 = 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 + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt new file mode 100644 index 0000000000..e310aeca94 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt @@ -0,0 +1,97 @@ +package com.tangem.features.txhistory.utils + +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +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, express: List): List { + val onChainByHash = onChain.associateBy { it.txHash } + val matchedHashes = mutableSetOf() + val result = mutableListOf() + + 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) + } +} + +/** + * Synthesizes a [TxInfo] view of an express op so it can be rendered by the existing + * [com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter]. Rendered as a + * [TxInfo.TransactionType.Swap] for now (onramp included). The amount is the viewed-currency leg. + */ +internal fun ExpressTx.toSyntheticTxInfo(): TxInfo { + val viewedAmount = when (this) { + is ExpressTx.Swap -> if (isOutgoing) tx.fromAsset.amount else tx.toAsset.amount + is ExpressTx.Onramp -> tx.toAsset.amount + } + val isOutgoing = when (this) { + is ExpressTx.Swap -> this.isOutgoing + is ExpressTx.Onramp -> false + } + return TxInfo( + // matchHash is the on-chain hash (== the matched leg's hash, enables the explorer link); else txId. + txHash = matchHash ?: txId, + timestampInMillis = timestampMillis, + isOutgoing = isOutgoing, + destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(address = "")), + sourceType = TxInfo.SourceType.Single(address = ""), + interactionAddressType = null, + status = toTransactionStatus(), + type = TxInfo.TransactionType.Swap, + amount = viewedAmount, + ) +} + +/** + * Maps the typed express status to the on-chain-shaped [TxInfo.TransactionStatus] used by the UI: + * the single success state (`Finished`) → Confirmed, any other terminal state → Failed, in-progress → Unconfirmed. + */ +private fun ExpressTx.toTransactionStatus(): TxInfo.TransactionStatus { + val isFinished = when (this) { + is ExpressTx.Swap -> tx.status == ExpressExchangeStatus.Finished + is ExpressTx.Onramp -> tx.status == ExpressOnrampStatus.Finished + } + return when { + isFinished -> TxInfo.TransactionStatus.Confirmed + isTerminal -> TxInfo.TransactionStatus.Failed + else -> TxInfo.TransactionStatus.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt index 4a13a25948..83b868fc40 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt @@ -2,6 +2,7 @@ package com.tangem.features.txhistory.utils import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.txhistory.model.identityKey import com.tangem.domain.txhistory.models.PaginationWrapper import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter import com.tangem.features.txhistory.entity.TxHistoryItemsUM @@ -96,13 +97,4 @@ internal class TxHistoryUiManager( private val TxHistoryListState.hasContent: Boolean get() = status !is PaginationStatus.None && status !is PaginationStatus.InitialLoading && - status !is PaginationStatus.InitialLoadingError - -/** - * Cross-batch identity of a tx: `txHash` alone is not enough because gasless flows surface several - * events under the same on-chain hash (e.g. `GaslessFee` + `Transfer`). Pinning the [TxInfo.type] - * keeps those legitimate sibling events apart while still collapsing the same event seen twice — - * e.g. an Unconfirmed copy injected via `addRecentTransactions` and a Confirmed copy that arrives - * in a later API batch. - */ -private fun TxInfo.identityKey(): String = "$txHash|$type" \ No newline at end of file + status !is PaginationStatus.InitialLoadingError \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt new file mode 100644 index 0000000000..7413a5ae9f --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt @@ -0,0 +1,142 @@ +package com.tangem.features.txhistory.utils + +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.network.TxInfo +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxHistoryInfoMergerTest { + + @Test + fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() { + // Arrange + val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100)) + val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting)) + + // Act + val result = mergeTxHistoryInfos(onChain, express) + + // Assert + assertThat(result).hasSize(1) + val row = result.single() + assertThat(row).isInstanceOf(ExpressTx.Swap::class.java) + assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java) + } + + @Test + fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() { + // Arrange + val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting)) + + // Act + val result = mergeTxHistoryInfos(onChain = emptyList(), express = express) + + // Assert + assertThat(result).hasSize(1) + assertThat((result.single() as ExpressTx).txInfo).isNull() + } + + @Test + fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() { + // Arrange + val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished)) + + // Act + val result = mergeTxHistoryInfos(onChain = emptyList(), express = express) + + // Assert + assertThat(result).hasSize(1) + val row = result.single() + assertThat(row).isInstanceOf(ExpressTx.Swap::class.java) + assertThat((row as ExpressTx).txInfo).isNull() + } + + @Test + fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() { + // Arrange + val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100)) + + // Act + val result = mergeTxHistoryInfos(onChain, express = emptyList()) + + // Assert + assertThat(result).hasSize(1) + assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java) + } + + @Test + fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() { + // Arrange + val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100)) + val express = listOf(createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting)) + + // Act + val result = mergeTxHistoryInfos(onChain, express) + + // Assert + assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder() + } + + @Test + fun `GIVEN outgoing swap WHEN toSyntheticTxInfo THEN viewed from-leg amount and swap type`() { + // Arrange + val swap = createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = true) + + // Act + val txInfo = swap.toSyntheticTxInfo() + + // Assert + assertThat(txInfo.isOutgoing).isTrue() + assertThat(txInfo.amount).isEqualTo(BigDecimal("1.5")) + assertThat(txInfo.type).isEqualTo(TxInfo.TransactionType.Swap) + assertThat(txInfo.status).isEqualTo(TxInfo.TransactionStatus.Unconfirmed) + } + + private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo( + txHash = txHash, + timestampInMillis = timestamp, + 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?, + status: ExpressExchangeStatus, + createdAtMillis: Long = 100, + isOutgoing: Boolean = true, + ) = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = status, + createdAtMillis = createdAtMillis, + provider = null, + payinHash = matchHash.takeIf { isOutgoing }, + payoutHash = matchHash.takeUnless { isOutgoing }, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = BigDecimal("1.5"), + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = BigDecimal("0.001"), + decimals = 8, + ), + ), + isOutgoing = isOutgoing, + txInfo = null, + ) +} \ No newline at end of file