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, val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */ /** Transaction last-update timestamp in ISO-8601 format */
// todo txHistory uncomment
/*
@Json(name = "updatedAt") @Json(name = "updatedAt")
val updatedAt: String, val updatedAt: String,
*/
/** Pay-in expiration timestamp in ISO-8601 format */ /** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill") @Json(name = "payTill")

View file

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

View file

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

View file

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

View file

@ -11,9 +11,23 @@ android {
dependencies { dependencies {
// region Kotlin implementation(projects.domain.legacy)
api(deps.kotlin.coroutines) implementation(projects.domain.common)
// endregion 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 // region Other libraries
implementation(deps.androidx.annotation) implementation(deps.androidx.annotation)

View file

@ -1,52 +1,102 @@
package com.tangem.data.txhistory.list 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.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash 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 * Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
* timestamp-DESC timeline. * timestamp-DESC timeline.
* *
* Per express op (matched to on-chain by [ExpressTx.matchHash]): * Per express op:
* - matched enrich: emit the express row carrying its on-chain leg; the on-chain tx(es) * 1. deterministic hash match (its [ExpressTx.matchHash] `payin_hash`/`payout_hash` against an on-chain hash);
* of that hash are collapsed into this row (not emitted standalone). * 2. if that fails, a [heuristic fallback][matchesOnChainHeuristically] by direction + address + amount + time,
* - unmatched standalone row (status shown, no on-chain leg). Both in-progress and terminal * scoped to the open [currency].
* (finished/failed) express ops are shown so the user always sees their deals. *
* 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]. * 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. * [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 onChainByHash = onChain.associateBy { it.txHash }
val matchedHashes = mutableSetOf<String>() val claimedHashes = mutableSetOf<String>()
val result = mutableListOf<TxHistoryInfo>() 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 -> express.forEach { op ->
val matched = op.matchHash?.let(onChainByHash::get) val byHash = op.matchHash?.let(onChainByHash::get)
if (matched != null) { if (byHash != null && byHash.txHash !in claimedHashes) {
result += op.withMatchedOnChain(OnChainTx.BSDK(matched)) result += op.withMatchedOnChain(OnChainTx.BSDK(byHash))
matchedHashes += matched.txHash claimedHashes += byHash.txHash
} else { } else {
result += op unmatched += op
} }
} }
onChain.forEach { tx -> // Phase 2 — heuristic fallback for ops whose on-chain hash the provider never returned. On ties, the on-chain
if (tx.txHash !in matchedHashes) { // tx closest in time to the deal creation wins (collision resolution). An op that still finds no leg is emitted
result += OnChainTx.BSDK(tx) // 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) return result.sortedByDescending(TxHistoryInfo::timestampMillis)
} }
/** /**
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the * TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
* express overlay. Same rules as [mergeTxHistoryInfos] matched express ops enrich (carry their * express overlay. Hash-based match only the heuristic fallback is BSDK-specific (it relies on
* on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through. * [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 * TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
* express op's [ExpressTx.matchHash]. * express op's [ExpressTx.matchHash].
@ -82,4 +132,182 @@ internal fun mergeTangemPay(onChain: List<OnChainTx.TangemPay>, express: List<Ex
private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) { private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) {
is ExpressTx.Swap -> copy(txInfo = onChain) is ExpressTx.Swap -> copy(txInfo = onChain)
is ExpressTx.Onramp -> 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 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.data.txhistory.list.mergeTxHistoryInfos
import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
@ -80,8 +81,7 @@ internal class BsdkOnChainHistory @AssistedInject constructor(
.flatMap { it.data.items.asSequence() } .flatMap { it.data.items.asSequence() }
.distinctBy(TxInfo::identityKey) .distinctBy(TxInfo::identityKey)
.toList() .toList()
mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
mergeTxHistoryInfos(onChain = onChain, express = express)
} }
return when (batchState.status) { return when (batchState.status) {

View file

@ -54,7 +54,11 @@ internal class IndexTableOnChainHistory @AssistedInject constructor(
} }
private fun buildState(page: ExpressHistoryPage): HistoryState { 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()) { return if (merged.isEmpty()) {
HistoryState.Empty HistoryState.Empty
} else { } 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.converter.OnrampCountryConverter
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId 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.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
@ -170,6 +171,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
isOutgoing = true, isOutgoing = true,
fromCurrency = currencies[entity.from.toAssetId()], fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()], toCurrency = currencies[entity.to.toAssetId()],
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
) )
add(swapConverter.convert(input)) add(swapConverter.convert(input))
} }
@ -180,6 +182,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
isOutgoing = false, isOutgoing = false,
fromCurrency = currencies[entity.from.toAssetId()], fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()], toCurrency = currencies[entity.to.toAssetId()],
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
) )
add(swapConverter.convert(input)) add(swapConverter.convert(input))
} }

View file

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

View file

@ -1,13 +1,22 @@
package com.tangem.data.txhistory.list package com.tangem.data.txhistory.list
import com.google.common.truth.Truth.assertThat 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.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus 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.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.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.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx 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.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal import java.math.BigDecimal
@ -15,95 +24,642 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryInfoMergerTest { internal class TxHistoryInfoMergerTest {
@Test @Nested
fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() { @TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Arrange inner class DeterministicHashMatch {
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting))
// Act @Test
val result = mergeTxHistoryInfos(onChain, express) 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 // Act
assertThat(result).hasSize(1) val result = merge(onChain, express)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java) // Assert
assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java) 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 @Nested
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() { @TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Arrange inner class HeuristicFallbackMatch {
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
// Act @Test
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express) 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 // Act
assertThat(result).hasSize(1) val result = merge(onChain, express, currency = TO_CURRENCY)
assertThat((result.single() as ExpressTx).txInfo).isNull()
// 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 @Nested
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() { @TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Arrange inner class CurrencyAndAmountScoping {
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
// Act @Test
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express) 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 // Act
assertThat(result).hasSize(1) val result = merge(onChain, express, currency = FROM_CURRENCY)
val row = result.single()
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java) // Assert
assertThat((row as ExpressTx).txInfo).isNull() 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 @Nested
fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() { @TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Arrange inner class UnmatchedTerminalHiding {
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
// Act @Test
val result = mergeTxHistoryInfos(onChain, express = emptyList()) 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 // Act
assertThat(result).hasSize(1) val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
// 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 private fun merge(
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() { onChain: List<TxInfo>,
// Arrange express: List<ExpressTx>,
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100)) currency: CryptoCurrency = FROM_CURRENCY,
val express = listOf( ): List<TxHistoryInfo> = mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
)
// Act private fun createTxInfo(
val result = mergeTxHistoryInfos(onChain, express) txHash: String,
timestamp: Long,
// Assert isOutgoing: Boolean = true,
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder() sourceAddress: String = "addr",
} destinationAddress: String = "addr",
amount: BigDecimal = BigDecimal.ONE,
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo( ) = TxInfo(
txHash = txHash, txHash = txHash,
timestampInMillis = timestamp, timestampInMillis = timestamp,
isOutgoing = true, isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")), destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(destinationAddress)),
sourceType = TxInfo.SourceType.Single("addr"), sourceType = TxInfo.SourceType.Single(sourceAddress),
interactionAddressType = null, interactionAddressType = null,
status = TxInfo.TransactionStatus.Confirmed, status = TxInfo.TransactionStatus.Confirmed,
type = TxInfo.TransactionType.Transfer, type = TxInfo.TransactionType.Transfer,
amount = BigDecimal.ONE, amount = amount,
) )
@Suppress("LongParameterList")
private fun createSwap( private fun createSwap(
matchHash: String?, matchHash: String?,
status: ExpressExchangeStatus, status: ExpressExchangeStatus,
createdAtMillis: Long = 100, createdAtMillis: Long = 100,
isOutgoing: Boolean = true, 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( ) = ExpressTx.Swap(
tx = ExchangeTransaction( tx = ExchangeTransaction(
txId = "tx-1", txId = "tx-1",
@ -112,20 +668,78 @@ internal class TxHistoryInfoMergerTest {
provider = null, provider = null,
payinHash = matchHash.takeIf { isOutgoing }, payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing }, payoutHash = matchHash.takeUnless { isOutgoing },
fromAddress = null, fromAddress = fromAddress,
payoutAddress = null, payoutAddress = payoutAddress,
fromAsset = ExpressTransactionAsset( fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"), id = ExpressAssetId(fromCurrency),
amount = BigDecimal("1.5"), amount = fromAmount,
decimals = 18, decimals = 18,
), ),
toAsset = ExpressTransactionAsset( toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), id = ExpressAssetId(toCurrency),
amount = BigDecimal("0.001"), amount = toAmount,
decimals = 8, decimals = 8,
), ),
externalTxUrl = null,
payinAddress = payinAddress,
updatedAtMillis = updatedAtMillis,
refundAssetId = null,
refundCurrency = null,
fromAmount = fromAmount ?: BigDecimal.ZERO,
toAmount = toAmount ?: BigDecimal.ZERO,
toActualAmount = toActualAmount,
), ),
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
txInfo = null, 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, provider = null,
payinHash = matchHash, payinHash = matchHash,
payoutHash = null, payoutHash = null,
fromAddress = null, fromAddress = "from-addr",
payoutAddress = null, payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset( fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"), id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = BigDecimal.ONE, amount = BigDecimal.ONE,
@ -243,6 +243,14 @@ internal class BsdkOnChainHistoryTest {
amount = BigDecimal.ONE, amount = BigDecimal.ONE,
decimals = 8, decimals = 8,
), ),
externalTxUrl = null,
payinAddress = "payin-addr",
updatedAtMillis = 100,
refundAssetId = null,
refundCurrency = null,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal.ONE,
toActualAmount = null,
), ),
isOutgoing = true, isOutgoing = true,
txInfo = null, txInfo = null,

View file

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

View file

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

View file

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

View file

@ -23,7 +23,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting") val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting")
// Act // 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 // Assert
assertThat(swap.isOutgoing).isTrue() assertThat(swap.isOutgoing).isTrue()
@ -42,7 +51,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout") val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout")
// Act // 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 // Assert
assertThat(swap.isOutgoing).isFalse() assertThat(swap.isOutgoing).isFalse()
@ -55,7 +73,16 @@ internal class ExpressTxHistoryConverterTest {
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000") val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
// Act // 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 // Assert
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099")) assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))

View file

@ -1,5 +1,8 @@
package com.tangem.domain.express.models 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. * 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 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 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 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 * @property fromAddress Address the `from` assets were taken from (the user's own source address).
* (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
* @property payoutAddress Address that received the `to` assets the user's own address for a regular swap, an external * external one for a send-and-swap.
* one for a send-and-swap; `null` when unknown.
* @property fromAsset The asset sent. * @property fromAsset The asset sent.
* @property toAsset The asset received. * @property toAsset The asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider * @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (CEX only). * 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( data class ExchangeTransaction(
val txId: String, val txId: String,
@ -27,9 +33,17 @@ data class ExchangeTransaction(
val provider: ExpressProvider?, val provider: ExpressProvider?,
val payinHash: String?, val payinHash: String?,
val payoutHash: String?, val payoutHash: String?,
val fromAddress: String?, val fromAddress: String,
val payoutAddress: String?, val payoutAddress: String,
val fromAsset: ExpressTransactionAsset, val fromAsset: ExpressTransactionAsset,
val toAsset: 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 -> 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 { companion object {
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown 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.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType 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. * 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 provider The provider behind the deal; `null` if not resolved.
* @property payoutHash On-chain hash of the payout (received) leg, if known. * @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 fromFiat The fiat paid.
* @property toAsset The crypto asset received. * @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 * @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (not provided by all providers). * supplies none (not provided by all providers).
* @property country The country the onramp was made from; `null` if not resolved.
*/ */
data class OnrampTransaction( data class OnrampTransaction(
val txId: String, val txId: String,
@ -27,10 +28,12 @@ data class OnrampTransaction(
val createdAtMillis: Long, val createdAtMillis: Long,
val provider: ExpressProvider?, val provider: ExpressProvider?,
val payoutHash: String?, val payoutHash: String?,
val payoutAddress: String?, val payoutAddress: String,
/** The [Amount.type] is [AmountType.FiatType] . */ /** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount, val fromFiat: Amount,
val toAsset: ExpressTransactionAsset, val toAsset: ExpressTransactionAsset,
val country: OnrampCountry? = null, val country: OnrampCountry?,
val externalTxUrl: String? = null, 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.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.TxHistoryFeatureToggles
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger 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.HistoryTxListManager
import com.tangem.domain.txhistory.list.txHistoryInfoFlow import com.tangem.domain.txhistory.list.txHistoryInfoFlow
import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.TxHistoryInfo
@ -346,10 +349,18 @@ internal class TxHistoryModel @Inject constructor(
override fun onTransactionClick(item: TxHistoryInfo) { override fun onTransactionClick(item: TxHistoryInfo) {
// manager is non-null only under the new tx-history toggle. // manager is non-null only under the new tx-history toggle.
val manager = historyTxListManager val manager = historyTxListManager
if (manager != null) { if (manager != null && item.opensInAppDetails()) {
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item)) params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
} else { } else {
item.explorerHash?.let(::openTxInExplorer) 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)) 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 @Test
fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() { fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() {
// Arrange // Arrange

View file

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

View file

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

View file

@ -108,8 +108,8 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
provider = null, provider = null,
payinHash = null, payinHash = null,
payoutHash = null, payoutHash = null,
fromAddress = null, fromAddress = "from-addr",
payoutAddress = null, payoutAddress = "payout-addr",
fromAsset = ExpressTransactionAsset( fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"), id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"),
amount = BigDecimal("1.5"), amount = BigDecimal("1.5"),
@ -120,6 +120,14 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
amount = BigDecimal("0.001"), amount = BigDecimal("0.001"),
decimals = 8, 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, isOutgoing = true,
txInfo = null, txInfo = null,