Updated on 2026-08-14
This commit is contained in:
parent
57a16f0871
commit
297304e6e7
10 changed files with 534 additions and 48 deletions
|
|
@ -2,4 +2,5 @@ package com.tangem.features.txhistory
|
|||
|
||||
interface TxHistoryFeatureToggles {
|
||||
val isSolanaTxHistoryEnabled: Boolean
|
||||
val isNewTxHistoryEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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<TxHistoryInfo, TransactionItemUM> {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<TxHistoryInfo>,
|
||||
lookup: TxHistoryLookupContext,
|
||||
): ImmutableList<TxHistoryItemsUM.TxHistoryItemUM> {
|
||||
val converter = TxHistoryInfoToTransactionItemUMConverter(
|
||||
txInfoConverter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = params.currency,
|
||||
txHistoryUiActions = this,
|
||||
lookupContext = lookup,
|
||||
),
|
||||
)
|
||||
|
||||
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
status !is PaginationStatus.InitialLoadingError
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue