Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 17:27:49 +04:00
parent ec8857a55e
commit 03c5bdd053
25 changed files with 1176 additions and 179 deletions

View file

@ -79,11 +79,8 @@ data class ExchangeItemResponse(
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
// todo txHistory uncomment
/*
@Json(name = "updatedAt")
val updatedAt: String,
*/
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")

View file

@ -44,11 +44,8 @@ data class OnrampItemResponse(
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
// todo txHistory uncomment
/*
@Json(name = "updatedAt")
val updatedAt: String,
*/
// endregion
// region fromAsset (fiat) info

View file

@ -30,7 +30,7 @@ fun ExchangeItemResponse.toEntity(): ExpressExchangeEntity? {
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment
updatedAt = updatedAt,
payTill = payTill,
averageDuration = averageDuration,
from = ExpressExchangeEntity.AssetEmbedded(
@ -61,7 +61,7 @@ fun OnrampItemResponse.toEntity(): ExpressOnrampEntity {
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = createdAt,
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment,
updatedAt = updatedAt,
fromCurrencyCode = fromCurrencyCode,
fromAmount = fromAmount,
fromPrecision = fromPrecision,

View file

@ -34,8 +34,7 @@ internal class ExpressHistoryConverterTest {
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
// todo txHistory uncomment
// Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@ -233,8 +232,7 @@ internal class ExpressHistoryConverterTest {
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
updatedAt = "2026-06-01T00:05:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
@ -266,8 +264,7 @@ internal class ExpressHistoryConverterTest {
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,

View file

@ -11,9 +11,23 @@ android {
dependencies {
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
implementation(projects.domain.legacy)
implementation(projects.domain.common)
implementation(projects.domain.walletManager)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.express)
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
// region Other libraries
implementation(deps.androidx.annotation)

View file

@ -1,52 +1,102 @@
package com.tangem.data.txhistory.list
import com.tangem.blockchain.common.isUTXO
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
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
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.abs
/**
* 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.
* Per express op:
* 1. deterministic hash match (its [ExpressTx.matchHash] `payin_hash`/`payout_hash` against an on-chain hash);
* 2. if that fails, a [heuristic fallback][matchesOnChainHeuristically] by direction + address + amount + time,
* scoped to the open [currency].
*
* The heuristic exists because the on-chain hash is not always returned by the provider the payout leg's hash
* is filled by status polling and may never arrive. Without it an incoming swap/onramp would surface twice: once
* as the standalone express row and once as its BSDK leg. Matching them collapses the pair into one enriched row.
*
* Per outcome:
* - 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), except a terminal-but-unsuccessful incoming swap
* leg whose on-chain credit never arrived, which is hidden (see [isHiddenWhenUnmatched]) so it does not
* surface as a phantom incoming row. A successful (finished) incoming swap, the outgoing / refund side,
* and every onramp purchase are always kept so the user still 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.
*
* @param currency the open currency: its [ExpressAsset.ID] scopes the heuristics to legs of this token (network +
* contract replacing the per-leg token/network check, since [TxInfo] carries no network of its own), and its
* chain decides whether the pay-in amount tolerates a small UTXO fee/dust variance or must match exactly.
*/
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
internal fun mergeTxHistoryInfos(
onChain: List<TxInfo>,
express: List<ExpressTx>,
currency: CryptoCurrency,
): List<TxHistoryInfo> {
val currencyAssetId: ExpressAsset.ID = ExpressAsset.ID(currency)
val isUtxoNetwork: Boolean = currency.network.toBlockchain().isUTXO
val onChainByHash = onChain.associateBy { it.txHash }
val matchedHashes = mutableSetOf<String>()
val claimedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>()
// Phase 1 — deterministic hash match takes priority (payin_hash / payout_hash via ExpressTx.matchHash).
val unmatched = mutableListOf<ExpressTx>()
express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get)
if (matched != null) {
result += op.withMatchedOnChain(OnChainTx.BSDK(matched))
matchedHashes += matched.txHash
val byHash = op.matchHash?.let(onChainByHash::get)
if (byHash != null && byHash.txHash !in claimedHashes) {
result += op.withMatchedOnChain(OnChainTx.BSDK(byHash))
claimedHashes += byHash.txHash
} else {
result += op
unmatched += op
}
}
onChain.forEach { tx ->
if (tx.txHash !in matchedHashes) {
result += OnChainTx.BSDK(tx)
// Phase 2 — heuristic fallback for ops whose on-chain hash the provider never returned. On ties, the on-chain
// tx closest in time to the deal creation wins (collision resolution). An op that still finds no leg is emitted
// standalone, unless it must be hidden (a terminal swap payout that never landed on-chain).
unmatched.forEach { op ->
val candidate = onChain
.filter { tx ->
tx.txHash !in claimedHashes && op.matchesOnChainHeuristically(tx, currencyAssetId, isUtxoNetwork)
}
.minByOrNull { abs(it.timestampInMillis - op.createdAtMillis) }
when {
candidate != null -> {
result += op.withMatchedOnChain(OnChainTx.BSDK(candidate))
claimedHashes += candidate.txHash
}
!op.isHiddenWhenUnmatched() -> result += op
}
}
// Phase 3 — on-chain txs no express op claimed pass through.
onChain.forEach { tx ->
if (tx.txHash !in claimedHashes) 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.
* express overlay. Hash-based match only the heuristic fallback is BSDK-specific (it relies on
* [TxInfo] direction/addresses/amount) and TangemPay is not wired into the history end-to-end yet.
*
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
* express op's [ExpressTx.matchHash].
@ -83,3 +133,181 @@ private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (
is ExpressTx.Swap -> copy(txInfo = onChain)
is ExpressTx.Onramp -> copy(txInfo = onChain)
}
/**
* Whether an express op that matched no on-chain leg must be hidden instead of surfacing standalone.
*
* Only a *terminal-but-unsuccessful* incoming (payout) leg of a swap is hidden one that ended in failure / refund /
* expiry without ever landing on-chain (or landing somewhere we do not page); a standalone "You receive" row there
* would be a phantom. A *successful* (finished) incoming swap is always kept even unmatched: the credit did arrive
* and must be shown whether or not we could join it to an on-chain tx the deciding case for the index-table
* backbone, where there is no on-chain row to fall back to. The outgoing / refund side of a swap
* ([ExpressTx.Swap.isOutgoing], viewed on the from-token screen where a refund lands) and every onramp purchase are
* always kept too.
*/
private fun ExpressTx.isHiddenWhenUnmatched(): Boolean = when (this) {
is ExpressTx.Swap -> isTerminal && !tx.status.isFinished && !isOutgoing
is ExpressTx.Onramp -> false
}
/**
* Does the on-chain [onChainTx] look like the leg of this express op, when their hashes did not line up?
*
* Dispatches by op kind and viewed direction. The matched leg's asset must be the open currency ([currencyAssetId])
* this replaces the per-leg `T.network == E.*_network && T.contract == E.*_contract` check ([TxInfo] itself carries
* no network):
* - outgoing swap the pay-in leg it sent;
* - refunded outgoing swap the incoming refund of the `from` asset that lands on the same screen;
* - incoming swap the payout leg it received (the case the missing payout hash breaks);
* - onramp the payout leg it received.
*/
private fun ExpressTx.matchesOnChainHeuristically(
onChainTx: TxInfo,
currencyAssetId: ExpressAsset.ID,
isUtxoNetwork: Boolean,
): Boolean = when (this) {
is ExpressTx.Swap -> when {
isOutgoing && !onChainTx.isOutgoing && tx.status == ExpressExchangeStatus.Refunded ->
tx.matchesRefund(onChainTx, currencyAssetId)
isOutgoing -> tx.matchesOutgoingPayin(onChainTx, currencyAssetId, isUtxoNetwork)
else -> tx.matchesIncomingPayout(onChainTx, currencyAssetId)
}
is ExpressTx.Onramp -> tx.matchesOnrampPayout(onChainTx, currencyAssetId)
}
/**
* Outgoing (pay-in) leg: the user sent the `from` asset to the provider deposit address. That address
* ([ExchangeTransaction.payinAddress]) is a per-deal discriminator; the sender and amount confirm it. The amount is
* matched exactly, allowing the small UTXO fee/dust variance ([OUTGOING_AMOUNT_TOLERANCE]) only on UTXO chains.
*/
private fun ExchangeTransaction.matchesOutgoingPayin(
onChainTx: TxInfo,
currencyAssetId: ExpressAsset.ID,
isUtxoNetwork: Boolean,
): Boolean {
if (!onChainTx.isOutgoing) return false
if (!fromAsset.id.matchesAssetId(currencyAssetId)) return false
if (onChainTx.destinationAddresses().none { it.matchesAddress(payinAddress) }) return false
if (!isSentFromUser(onChainTx)) return false
val tolerance = if (isUtxoNetwork) OUTGOING_AMOUNT_TOLERANCE else EXACT_AMOUNT_TOLERANCE
return onChainTx.amount.matchesWithin(fromAmount, tolerance)
}
/**
* Incoming (payout) leg: the `to` asset landed on the user's payout address. This is where the missing
* payout hash bites, so address + amount + a 24h window carry the match; the sender must not be the user
* (self-transfer exclusion). Amount matches the actual settled value exactly when known
* ([ExchangeTransaction.toActualAmount]), otherwise the expected value within the slippage tolerance.
*/
private fun ExchangeTransaction.matchesIncomingPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
if (onChainTx.isOutgoing) return false
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
if (isSentFromUser(onChainTx)) return false // self-transfer, not a provider payout
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
return false
}
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
}
/**
* Refund leg: a refunded swap returns the `from` asset to the user as an incoming tx on the from-token
* screen. Matched by direction + status + amount (within a wider tolerance for fees/volatility) + a window
* that extends to `updatedAt + 24h`, since the refund is credited after the deal was last updated. When the refund
* asset is known ([ExchangeTransaction.refundAssetId]) it must be the open currency.
*/
private fun ExchangeTransaction.matchesRefund(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
if (onChainTx.isOutgoing) return false
val refundId = refundAssetId
if (refundId != null && !refundId.matchesAssetId(currencyAssetId)) return false
if (isSentFromUser(onChainTx)) return false // refund comes from the provider, not the user
if (!isWithinWindow(from = createdAtMillis, upperBase = updatedAtMillis, ts = onChainTx.timestampInMillis)) {
return false
}
return onChainTx.amount.matchesWithin(fromAmount, REFUND_AMOUNT_TOLERANCE)
}
/**
* Onramp payout leg: bought crypto landed on the user's payout address. No `from` address to exclude
* (the source is fiat), so address + amount + the 24h window carry the match.
*/
private fun OnrampTransaction.matchesOnrampPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
if (onChainTx.isOutgoing) return false
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
return false
}
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
}
/**
* Amount match for an incoming leg: the [actual] settled amount matched exactly when known, otherwise the
* [expected] amount within [INCOMING_SLIPPAGE_TOLERANCE]; no match when neither is known.
*/
private fun matchesIncomingAmount(txAmount: BigDecimal, actual: BigDecimal?, expected: BigDecimal?): Boolean = when {
actual != null -> txAmount.matchesWithin(actual, EXACT_AMOUNT_TOLERANCE)
expected != null -> txAmount.matchesWithin(expected, INCOMING_SLIPPAGE_TOLERANCE)
else -> false
}
/** Whether this asset id is the open currency's asset (network id + contract), tolerant of EVM contract casing. */
private fun ExpressAsset.ID.matchesAssetId(other: ExpressAsset.ID): Boolean =
networkId.equals(other.networkId, ignoreCase = true) &&
contractAddress.equals(other.contractAddress, ignoreCase = true)
/** Relative-difference amount comparison: `|this - target| / |target| <= tolerance`. */
private fun BigDecimal.matchesWithin(target: BigDecimal, tolerance: BigDecimal): Boolean {
if (target.signum() == 0) return signum() == 0
val relativeDiff = (this - target).abs().divide(target.abs(), AMOUNT_SCALE, RoundingMode.HALF_UP)
return relativeDiff <= tolerance
}
/** Inclusive time window `[from, upperBase + 24h]`. */
private fun isWithinWindow(from: Long, upperBase: Long, ts: Long): Boolean {
val upperBound = upperBase + TIME_WINDOW_MILLIS
return ts in from..upperBound
}
/**
* Whether the on-chain tx was sent from the user's own `from` address i.e. a self-transfer, not a
* provider payout/refund.
*/
private fun ExchangeTransaction.isSentFromUser(onChainTx: TxInfo): Boolean {
val from = fromAddress
return onChainTx.sourceAddresses().any { it.matchesAddress(from) }
}
/**
* Address equality tolerant of case: EVM addresses differ only by checksum casing, and other chains'
* distinct addresses won't collide case-insensitively in practice.
*/
private fun String.matchesAddress(other: String): Boolean = equals(other, ignoreCase = true)
private fun TxInfo.sourceAddresses(): List<String> = when (val source = sourceType) {
is TxInfo.SourceType.Single -> listOf(source.address)
is TxInfo.SourceType.Multiple -> source.addresses
}
private fun TxInfo.destinationAddresses(): List<String> = when (val destination = destinationType) {
is TxInfo.DestinationType.Single -> listOf(destination.addressType.address)
is TxInfo.DestinationType.Multiple -> destination.addressTypes.map { it.address }
}
/** Scale used when computing relative amount differences. */
private const val AMOUNT_SCALE = 10
/** 24h credit/refund buffer for the incoming and refund time windows. */
private const val TIME_WINDOW_MILLIS = 86_400_000L
/** 0% — exact match, used for the pay-in amount off UTXO chains and for the confirmed actual settled amount. */
private val EXACT_AMOUNT_TOLERANCE = BigDecimal.ZERO
/** 0.1% — absorbs UTXO fee/dust variance on the pay-in amount (applied only on UTXO chains). */
private val OUTGOING_AMOUNT_TOLERANCE = BigDecimal("0.001")
/** 5% — payout slippage vs the expected `to` amount (used only when no actual amount is known). */
private val INCOMING_SLIPPAGE_TOLERANCE = BigDecimal("0.05")
/** 15% — fees + volatility on the refunded amount vs the original `from` amount. */
private val REFUND_AMOUNT_TOLERANCE = BigDecimal("0.15")

View file

@ -2,6 +2,7 @@
package com.tangem.data.txhistory.list.chain
import com.tangem.data.txhistory.list.chain.BsdkOnChainHistory.Companion.AUTO_LOAD_MORE_TARGET_COUNT
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
@ -80,8 +81,7 @@ internal class BsdkOnChainHistory @AssistedInject constructor(
.flatMap { it.data.items.asSequence() }
.distinctBy(TxInfo::identityKey)
.toList()
mergeTxHistoryInfos(onChain = onChain, express = express)
mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
}
return when (batchState.status) {

View file

@ -54,7 +54,11 @@ internal class IndexTableOnChainHistory @AssistedInject constructor(
}
private fun buildState(page: ExpressHistoryPage): HistoryState {
val merged = mergeTxHistoryInfos(onChain = emptyList(), express = page.items)
val merged = mergeTxHistoryInfos(
onChain = emptyList(),
express = page.items,
currency = env.currency,
)
return if (merged.isEmpty()) {
HistoryState.Empty
} else {

View file

@ -8,6 +8,7 @@ import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.data.txhistory.repository.factory.toRefundAssetId
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
@ -170,6 +171,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
isOutgoing = true,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
)
add(swapConverter.convert(input))
}
@ -180,6 +182,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
isOutgoing = false,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
)
add(swapConverter.convert(input))
}

View file

@ -1,14 +1,9 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.data.txhistory.repository.factory.toRefundAssetId
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
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.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.express.models.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
@ -17,6 +12,7 @@ import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
import java.math.BigDecimal
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
/**
* Maps an exchange entity into a swap [ExpressTx.Swap].
@ -37,8 +33,9 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
val fromCurrency: CryptoCurrency? = null,
val toCurrency: CryptoCurrency? = null,
val fromCurrency: CryptoCurrency?,
val toCurrency: CryptoCurrency?,
val refundCurrency: CryptoCurrency?,
)
}
@ -46,6 +43,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
override fun convert(value: Input): ExpressTx.Onramp {
val entity = value.entity
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals)
return ExpressTx.Onramp(
tx = OnrampTransaction(
txId = entity.txId,
@ -58,16 +56,18 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
decimals = entity.fromPrecision,
type = AmountType.FiatType(code = entity.fromCurrencyCode),
type = AmountType.FiatType(code = value.country?.defaultCurrency?.unit ?: entity.fromCurrencyCode),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
amount = toActualAmount,
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
country = value.country,
externalTxUrl = entity.externalTxUrl,
toAmount = entity.to.amount?.toScaledBigDecimal(entity.to.decimals),
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
),
txInfo = null,
)
@ -83,6 +83,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
val entity = value.entity
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals)
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
@ -100,11 +101,19 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
amount = toActualAmount,
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
externalTxUrl = entity.externalTxUrl,
payinAddress = entity.payinAddress,
updatedAtMillis = parseIsoMillis(entity.updatedAt),
refundAssetId = entity.toRefundAssetId(),
refundCurrency = value.refundCurrency,
fromAmount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
toAmount = entity.to.amount.toScaledBigDecimal(entity.to.decimals),
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
)
}

View file

@ -41,6 +41,7 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
(outgoingSwaps + incomingSwaps).forEach { entity ->
add(entity.from.toAssetId())
add(entity.to.toAssetId())
entity.toRefundAssetId()?.let { add(it) }
}
onramps.forEach { entity -> add(entity.to.toAssetId()) }
}
@ -125,5 +126,15 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
internal fun ExpressExchangeEntity.toRefundAssetId(): ExpressAsset.ID? {
val refundNetwork = refundNetwork
val refundContractAddress = refundContractAddress
return if (refundNetwork != null && refundContractAddress != null) {
ExpressAsset.ID(refundNetwork, refundContractAddress)
} else {
null
}
}
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)

View file

@ -1,13 +1,22 @@
package com.tangem.data.txhistory.list
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
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.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
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
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -15,95 +24,642 @@ 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))
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class DeterministicHashMatch {
// Act
val result = mergeTxHistoryInfos(onChain, express)
@Test
fun `GIVEN express op matched to on-chain by hash 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))
// 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)
// Act
val result = merge(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 = merge(onChain = emptyList(), express = express)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN unmatched terminal outgoing swap WHEN merge THEN standalone row kept`() {
// Arrange: the outgoing (pay-in) side is always kept — see UnmatchedTerminalHiding for the hidden case.
val express = listOf(
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished, isOutgoing = true),
)
// Act
val result = merge(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 = merge(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 = merge(onChain, express)
// Assert
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
}
}
@Test
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() {
// Arrange
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class HeuristicFallbackMatch {
// Act
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
@Test
fun `GIVEN incoming swap without payout hash WHEN on-chain credit matches address amount time THEN one enriched row`() {
// Arrange: the provider never returned the payout hash, so only the heuristic can join the pair.
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Finished,
isOutgoing = false,
createdAtMillis = 100,
fromAddress = "mySource",
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
assertThat((row as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
}
@Test
fun `GIVEN incoming swap WHEN on-chain sender is the user THEN self-transfer not matched and both rows kept`() {
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "mySource",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Waiting,
isOutgoing = false,
createdAtMillis = 100,
fromAddress = "mySource",
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
@Test
fun `GIVEN incoming swap WHEN on-chain tx is outside the 24h window THEN not matched`() {
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 100 + DAY_MILLIS + 1,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Waiting,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
@Test
fun `GIVEN incoming swap with expected amount only WHEN on-chain amount is beyond slippage THEN not matched`() {
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.01"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Waiting,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
@Test
fun `GIVEN outgoing swap without payin hash WHEN on-chain pay-in matches deposit address and amount THEN matched`() {
// Arrange
val onChain = listOf(
createTxInfo(
txHash = "payin",
timestamp = 120,
isOutgoing = true,
sourceAddress = "mySource",
destinationAddress = "providerDeposit",
amount = BigDecimal("1.5"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Confirming,
isOutgoing = true,
createdAtMillis = 100,
fromAddress = "mySource",
payinAddress = "providerDeposit",
fromAmount = BigDecimal("1.5"),
),
)
// Act
val result = merge(onChain, express, currency = FROM_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("payin")
}
@Test
fun `GIVEN refunded swap WHEN incoming refund is within window and amount tolerance THEN matched`() {
// Arrange: refund returns the from-asset to the user on the from-token screen.
val onChain = listOf(
createTxInfo(
txHash = "refund",
timestamp = 2000,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "mySource",
amount = BigDecimal("1.4"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Refunded,
isOutgoing = true,
createdAtMillis = 100,
updatedAtMillis = 1000,
fromAddress = "mySource",
fromAmount = BigDecimal("1.5"),
),
)
// Act
val result = merge(onChain, express, currency = FROM_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("refund")
}
@Test
fun `GIVEN onramp without payout hash WHEN on-chain credit matches address amount time THEN matched`() {
// Arrange
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createOnramp(
matchHash = null,
status = ExpressOnrampStatus.Finished,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
}
@Test
fun `GIVEN two on-chain candidates WHEN incoming swap matches both THEN the closest in time is claimed and the other passes through`() {
// Arrange
val near = createTxInfo(
txHash = "near",
timestamp = 150,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
)
val far = createTxInfo(
txHash = "far",
timestamp = 500,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Finished,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Act
val result = merge(onChain = listOf(near, far), express = express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(2)
val enriched = result.filterIsInstance<ExpressTx>().single()
assertThat(enriched.txInfo?.explorerHash).isEqualTo("near")
val passthrough = result.filterIsInstance<OnChainTx.BSDK>().single()
assertThat(passthrough.txInfo.txHash).isEqualTo("far")
}
}
@Test
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() {
// Arrange
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CurrencyAndAmountScoping {
// Act
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
@Test
fun `GIVEN incoming swap WHEN open currency is not the to-asset THEN not matched`() {
// Arrange: address/amount/time all line up, but the open currency is the swap's from-asset, not the to-asset.
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Waiting,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
),
)
// Assert
assertThat(result).hasSize(1)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
assertThat((row as ExpressTx).txInfo).isNull()
// Act
val result = merge(onChain, express, currency = FROM_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
@Test
fun `GIVEN incoming swap with actual amount WHEN on-chain equals actual THEN matched`() {
// Arrange: the settled amount differs from the expected one; the actual value carries the match.
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.00095"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Finished,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
toActualAmount = BigDecimal("0.00095"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
}
@Test
fun `GIVEN incoming swap with actual amount WHEN on-chain differs from actual THEN not matched despite expected slippage`() {
// Arrange: on-chain 0.001 is within 5% of the expected 0.001, but the actual is 0.0009 and matched exactly.
val onChain = listOf(
createTxInfo(
txHash = "credit",
timestamp = 200,
isOutgoing = false,
sourceAddress = "provider",
destinationAddress = "myPayout",
amount = BigDecimal("0.001"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Waiting,
isOutgoing = false,
createdAtMillis = 100,
payoutAddress = "myPayout",
toAmount = BigDecimal("0.001"),
toActualAmount = BigDecimal("0.0009"),
),
)
// Act
val result = merge(onChain, express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
@Test
fun `GIVEN outgoing pay-in on UTXO chain WHEN amount is within dust tolerance THEN matched`() {
// Arrange: 1.5008 vs 1.5 is ~0.05% off — within the 0.1% UTXO fee/dust tolerance.
val onChain = listOf(
createTxInfo(
txHash = "payin",
timestamp = 120,
isOutgoing = true,
sourceAddress = "mySource",
destinationAddress = "providerDeposit",
amount = BigDecimal("1.5008"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Confirming,
isOutgoing = true,
createdAtMillis = 100,
fromAddress = "mySource",
payinAddress = "providerDeposit",
fromAmount = BigDecimal("1.5"),
fromCurrency = UTXO_CURRENCY,
),
)
// Act
val result = merge(onChain, express, currency = UTXO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("payin")
}
@Test
fun `GIVEN outgoing pay-in on non-UTXO chain WHEN amount is not exact THEN not matched`() {
// Arrange: same ~0.05% offset, but off UTXO the pay-in amount must match exactly.
val onChain = listOf(
createTxInfo(
txHash = "payin",
timestamp = 120,
isOutgoing = true,
sourceAddress = "mySource",
destinationAddress = "providerDeposit",
amount = BigDecimal("1.5008"),
),
)
val express = listOf(
createSwap(
matchHash = null,
status = ExpressExchangeStatus.Confirming,
isOutgoing = true,
createdAtMillis = 100,
fromAddress = "mySource",
payinAddress = "providerDeposit",
fromAmount = BigDecimal("1.5"),
),
)
// Act
val result = merge(onChain, express, currency = FROM_CURRENCY)
// Assert
assertThat(result).hasSize(2)
}
}
@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))
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class UnmatchedTerminalHiding {
// Act
val result = mergeTxHistoryInfos(onChain, express = emptyList())
@Test
fun `GIVEN unmatched terminal unsuccessful incoming swap WHEN merge THEN hidden`() {
// Arrange: to-token screen (isOutgoing = false), deal ended (expired) without ever landing on-chain.
val express = listOf(
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Expired, isOutgoing = false),
)
// Assert
assertThat(result).hasSize(1)
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
// Act
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN unmatched finished incoming swap WHEN merge THEN standalone row kept`() {
// Arrange: a successful incoming swap is always shown, even when its on-chain credit could not be matched
// (e.g. the index-table backbone has no on-chain row to fall back to).
val express = listOf(
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished, isOutgoing = false),
)
// Act
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN unmatched active incoming swap WHEN merge THEN standalone row kept`() {
// Arrange: not terminal yet, so the live row must still be shown.
val express = listOf(
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = false),
)
// Act
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN unmatched terminal refunded swap on from-token WHEN merge THEN standalone row kept`() {
// Arrange: refund lands on the open from-token (isOutgoing = true), so it is never hidden.
val express = listOf(
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Refunded, isOutgoing = true),
)
// Act
val result = merge(onChain = emptyList(), express = express, currency = FROM_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
@Test
fun `GIVEN unmatched terminal onramp WHEN merge THEN standalone row kept`() {
// Arrange: an onramp purchase is always kept, even terminal without an on-chain leg.
val express = listOf(createOnramp(matchHash = "missing", status = ExpressOnrampStatus.Finished))
// Act
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
// Assert
assertThat(result).hasSize(1)
assertThat((result.single() as ExpressTx).txInfo).isNull()
}
}
@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),
)
private fun merge(
onChain: List<TxInfo>,
express: List<ExpressTx>,
currency: CryptoCurrency = FROM_CURRENCY,
): List<TxHistoryInfo> = mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
// Act
val result = mergeTxHistoryInfos(onChain, express)
// Assert
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
}
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
private fun createTxInfo(
txHash: String,
timestamp: Long,
isOutgoing: Boolean = true,
sourceAddress: String = "addr",
destinationAddress: String = "addr",
amount: BigDecimal = BigDecimal.ONE,
) = TxInfo(
txHash = txHash,
timestampInMillis = timestamp,
isOutgoing = true,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
sourceType = TxInfo.SourceType.Single("addr"),
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(destinationAddress)),
sourceType = TxInfo.SourceType.Single(sourceAddress),
interactionAddressType = null,
status = TxInfo.TransactionStatus.Confirmed,
type = TxInfo.TransactionType.Transfer,
amount = BigDecimal.ONE,
amount = amount,
)
@Suppress("LongParameterList")
private fun createSwap(
matchHash: String?,
status: ExpressExchangeStatus,
createdAtMillis: Long = 100,
isOutgoing: Boolean = true,
updatedAtMillis: Long = createdAtMillis,
fromAddress: String = "fromAddr",
payoutAddress: String = "payoutAddr",
payinAddress: String = "payinAddr",
fromAmount: BigDecimal? = BigDecimal("1.5"),
toAmount: BigDecimal? = BigDecimal("0.001"),
toActualAmount: BigDecimal? = null,
fromCurrency: CryptoCurrency = FROM_CURRENCY,
toCurrency: CryptoCurrency = TO_CURRENCY,
) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
@ -112,20 +668,78 @@ internal class TxHistoryInfoMergerTest {
provider = null,
payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing },
fromAddress = null,
payoutAddress = null,
fromAddress = fromAddress,
payoutAddress = payoutAddress,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal("1.5"),
id = ExpressAssetId(fromCurrency),
amount = fromAmount,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = BigDecimal("0.001"),
id = ExpressAssetId(toCurrency),
amount = toAmount,
decimals = 8,
),
externalTxUrl = null,
payinAddress = payinAddress,
updatedAtMillis = updatedAtMillis,
refundAssetId = null,
refundCurrency = null,
fromAmount = fromAmount ?: BigDecimal.ZERO,
toAmount = toAmount ?: BigDecimal.ZERO,
toActualAmount = toActualAmount,
),
isOutgoing = isOutgoing,
txInfo = null,
)
private fun createOnramp(
matchHash: String?,
status: ExpressOnrampStatus,
createdAtMillis: Long = 100,
payoutAddress: String = "payoutAddr",
toAmount: BigDecimal? = BigDecimal("0.001"),
toActualAmount: BigDecimal? = null,
toCurrency: CryptoCurrency = TO_CURRENCY,
) = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
status = status,
createdAtMillis = createdAtMillis,
provider = null,
payoutHash = matchHash,
payoutAddress = payoutAddress,
fromFiat = Amount(
currencySymbol = "USD",
value = BigDecimal.TEN,
decimals = 2,
type = AmountType.FiatType(code = "USD"),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(toCurrency),
amount = toAmount,
decimals = 8,
),
externalTxUrl = null,
country = null,
toAmount = toAmount,
toActualAmount = toActualAmount,
),
txInfo = null,
)
private companion object {
const val DAY_MILLIS = 86_400_000L
private val currencyFactory = MockCryptoCurrencyFactory()
/** Open currency = the swap's `from` / pay-in asset (non-UTXO chain). */
val FROM_CURRENCY: CryptoCurrency = currencyFactory.ethereum
/** Open currency = the swap's `to` / payout (and onramp) asset. */
val TO_CURRENCY: CryptoCurrency = currencyFactory.stellar
/** UTXO chain, used to exercise the pay-in dust tolerance. */
val UTXO_CURRENCY: CryptoCurrency = currencyFactory.bitcoin
}
}

View file

@ -231,8 +231,8 @@ internal class BsdkOnChainHistoryTest {
provider = null,
payinHash = matchHash,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAddress = "from-addr",
payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
@ -243,6 +243,14 @@ internal class BsdkOnChainHistoryTest {
amount = BigDecimal.ONE,
decimals = 8,
),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = 100,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal.ONE,
toActualAmount = null,
),
isOutgoing = true,
txInfo = null,

View file

@ -119,8 +119,8 @@ internal class IndexTableOnChainHistoryTest {
provider = null,
payinHash = null,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAddress = "from-addr",
payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
@ -131,6 +131,14 @@ internal class IndexTableOnChainHistoryTest {
amount = BigDecimal.ONE,
decimals = 8,
),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = 100,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal.ONE,
toActualAmount = null,
),
isOutgoing = true,
txInfo = null,

View file

@ -158,8 +158,8 @@ internal class TangemPayOnChainHistoryTest {
provider = null,
payinHash = matchHash,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAddress = "from-addr",
payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE,
@ -170,6 +170,14 @@ internal class TangemPayOnChainHistoryTest {
amount = BigDecimal.ONE,
decimals = 8,
),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = 100,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal.ONE,
toActualAmount = null,
),
isOutgoing = true,
txInfo = null,

View file

@ -333,6 +333,7 @@ internal class DefaultExpressHistoryRepositoryTest {
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:00:00Z",
payTill = null,
averageDuration = null,
fromContractAddress = "0xfromContract",
@ -356,6 +357,7 @@ internal class DefaultExpressHistoryRepositoryTest {
externalTxUrl = null,
payoutHash = "payout-hash",
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:00:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,

View file

@ -23,7 +23,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
val swap = swapConverter.convert(
ExpressSwapConverter.Input(
entity = entity,
provider = null,
isOutgoing = true,
fromCurrency = null,
toCurrency = null,
refundCurrency = null,
),
)
// Assert
assertThat(swap.isOutgoing).isTrue()
@ -42,7 +51,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = false))
val swap = swapConverter.convert(
ExpressSwapConverter.Input(
entity = entity,
provider = null,
isOutgoing = false,
fromCurrency = null,
toCurrency = null,
refundCurrency = null,
),
)
// Assert
assertThat(swap.isOutgoing).isFalse()
@ -55,7 +73,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
val swap = swapConverter.convert(
ExpressSwapConverter.Input(
entity = entity,
provider = null,
isOutgoing = true,
fromCurrency = null,
toCurrency = null,
refundCurrency = null,
),
)
// Assert
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))

View file

@ -1,5 +1,8 @@
package com.tangem.domain.express.models
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
/**
* An express exchange (swap) operation, independent of how it is presented in the transaction history.
*
@ -11,14 +14,17 @@ package com.tangem.domain.express.models
* @property provider The provider behind the deal; `null` if not resolved.
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
* @property fromAddress Address the `from` assets were taken from (the user's own source address); `null` when unknown
* (very old app versions did not send it).
* @property payoutAddress Address that received the `to` assets the user's own address for a regular swap, an external
* one for a send-and-swap; `null` when unknown.
* @property fromAddress Address the `from` assets were taken from (the user's own source address).
* @property payoutAddress Address that received the `to` assets the user's own address for a regular swap, an
* external one for a send-and-swap.
* @property fromAsset The asset sent.
* @property toAsset The asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (CEX only).
* @property payinAddress Provider deposit address the pay-in was sent to the per-deal discriminator for the
* heuristic on-chain match of the outgoing (pay-in) leg.
* @property updatedAtMillis Last status-update timestamp (ms since epoch). Bounds the refund heuristic time
*/
data class ExchangeTransaction(
val txId: String,
@ -27,9 +33,17 @@ data class ExchangeTransaction(
val provider: ExpressProvider?,
val payinHash: String?,
val payoutHash: String?,
val fromAddress: String?,
val payoutAddress: String?,
val fromAddress: String,
val payoutAddress: String,
val fromAsset: ExpressTransactionAsset,
val toAsset: ExpressTransactionAsset,
val externalTxUrl: String? = null,
val externalTxUrl: String?,
val payinAddress: String,
val updatedAtMillis: Long,
val refundAssetId: ExpressAsset.ID?,
val refundCurrency: CryptoCurrency?,
val fromAmount: BigDecimal,
val toAmount: BigDecimal,
val toActualAmount: BigDecimal?,
)

View file

@ -53,6 +53,28 @@ enum class ExpressExchangeStatus(val raw: String) {
-> false
}
val isFinished: Boolean
get() = when (this) {
Finished,
-> true
Preview,
Expired,
Unknown,
Refunded,
TxFailed,
Paused,
Created,
ExchangeTxSent,
Waiting,
WaitingTxHash,
Confirming,
Exchanging,
Sending,
Failed,
Verifying,
-> false
}
companion object {
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.express.models
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import java.math.BigDecimal
/**
* An express onramp operation, independent of how it is presented in the transaction history.
@ -14,12 +15,12 @@ import com.tangem.domain.tokens.model.AmountType
* @property provider The provider behind the deal; `null` if not resolved.
* @property payoutHash On-chain hash of the payout (received) leg, if known.
* @property payoutAddress Address that received the crypto (the user's own address); `null` when unknown.
* @property payoutAddress Address that received the crypto (the user's own address).
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
* @property country The country the onramp was made from; `null` if not resolved.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (not provided by all providers).
* @property country The country the onramp was made from; `null` if not resolved.
*/
data class OnrampTransaction(
val txId: String,
@ -27,10 +28,12 @@ data class OnrampTransaction(
val createdAtMillis: Long,
val provider: ExpressProvider?,
val payoutHash: String?,
val payoutAddress: String?,
val payoutAddress: String,
/** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount,
val toAsset: ExpressTransactionAsset,
val country: OnrampCountry? = null,
val externalTxUrl: String? = null,
val country: OnrampCountry?,
val externalTxUrl: String?,
val toAmount: BigDecimal?,
val toActualAmount: BigDecimal?,
)

View file

@ -12,9 +12,12 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
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.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.list.HistoryTxListManager
import com.tangem.domain.txhistory.list.txHistoryInfoFlow
import com.tangem.domain.txhistory.model.TxHistoryInfo
@ -346,10 +349,18 @@ internal class TxHistoryModel @Inject constructor(
override fun onTransactionClick(item: TxHistoryInfo) {
// manager is non-null only under the new tx-history toggle.
val manager = historyTxListManager
if (manager != null) {
if (manager != null && item.opensInAppDetails()) {
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
} else {
item.explorerHash?.let(::openTxInExplorer)
}
}
}
/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */
private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) {
is ExpressTx -> true
is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap
// Standalone TangemPay rows are not yet rendered in-app; route them to the explorer for now.
is OnChainTx.TangemPay -> false
}

View file

@ -483,18 +483,6 @@ internal class ExpressTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
}
@Test
fun `GIVEN swap without addresses WHEN convert THEN no owner and default labels`() {
// Act — no fromAddress / payoutAddress plumbed (e.g. very old app version).
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
// Assert
assertThat(result.from?.owner).isNull()
assertThat(result.to?.owner).isNull()
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.swapping_from_title_v2))
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.swapping_to_title))
}
@Test
fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() {
// Arrange

View file

@ -226,8 +226,8 @@ internal class ExpressTxToTransactionItemUMConverterTest {
provider = null,
payinHash = null,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAddress = "from-addr",
payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = fromAmount,
@ -238,34 +238,48 @@ internal class ExpressTxToTransactionItemUMConverterTest {
amount = toAmount,
decimals = 8,
),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = 100,
refundAssetId = null,
refundCurrency = null,
fromAmount = fromAmount ?: BigDecimal.ZERO,
toAmount = toAmount ?: BigDecimal.ZERO,
toActualAmount = null,
),
isOutgoing = isOutgoing,
txInfo = null,
)
private fun createOnramp(status: ExpressOnrampStatus, toAmount: BigDecimal? = BigDecimal("0.006339")) =
ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
payoutAddress = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
private fun createOnramp(
status: ExpressOnrampStatus,
toAmount: BigDecimal? = BigDecimal("0.006339"),
) = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
payoutAddress = "payout-addr",
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
txInfo = null,
)
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
externalTxUrl = null,
country = null,
toAmount = toAmount,
toActualAmount = null,
),
txInfo = null,
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(

View file

@ -166,8 +166,8 @@ internal open class TxDetailsConverterTestBase {
txInfo: OnChainTx? = null,
provider: ExpressProvider? = null,
externalTxUrl: String? = null,
fromAddress: String? = null,
payoutAddress: String? = null,
fromAddress: String = FROM_ADDRESS,
payoutAddress: String = PAYOUT_ADDRESS,
fromCurrency: CryptoCurrency? = null,
): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
@ -192,6 +192,13 @@ internal open class TxDetailsConverterTestBase {
cryptoCurrency = bitcoin,
),
externalTxUrl = externalTxUrl,
payinAddress = "payin-addr",
updatedAtMillis = TIMESTAMP,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("0.001"),
toActualAmount = null,
),
isOutgoing = isOutgoing,
txInfo = txInfo,
@ -201,7 +208,7 @@ internal open class TxDetailsConverterTestBase {
status: ExpressOnrampStatus,
txInfo: OnChainTx? = null,
externalTxUrl: String? = null,
payoutAddress: String? = null,
payoutAddress: String = PAYOUT_ADDRESS,
): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
@ -223,6 +230,9 @@ internal open class TxDetailsConverterTestBase {
decimals = 8,
cryptoCurrency = bitcoin,
),
country = null,
toAmount = BigDecimal("0.006"),
toActualAmount = null,
),
txInfo = txInfo,
)

View file

@ -108,8 +108,8 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
provider = null,
payinHash = null,
payoutHash = null,
fromAddress = null,
payoutAddress = null,
fromAddress = "from-addr",
payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"),
amount = BigDecimal("1.5"),
@ -120,6 +120,14 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
amount = BigDecimal("0.001"),
decimals = 8,
),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = TIMESTAMP,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("0.001"),
toActualAmount = null,
),
isOutgoing = true,
txInfo = null,