Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-18 16:36:54 +04:00
parent 57a16f0871
commit 297304e6e7
10 changed files with 534 additions and 48 deletions

View file

@ -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"

View file

@ -2,4 +2,5 @@ package com.tangem.features.txhistory
interface TxHistoryFeatureToggles {
val isSolanaTxHistoryEnabled: Boolean
val isNewTxHistoryEnabled: Boolean
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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(

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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,
)
}