diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt index 06fb8713a8..7c5d86f608 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeItemResponse.kt @@ -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") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt index b9337deaf8..fa5767bf7b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/onramp/models/response/OnrampItemResponse.kt @@ -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 diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt index 7e7b48acab..edbcb176b1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt @@ -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, diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt index 9b605bde93..d866cabf09 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt @@ -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, diff --git a/data/txhistory/build.gradle.kts b/data/txhistory/build.gradle.kts index 0c27a50143..4964929c94 100644 --- a/data/txhistory/build.gradle.kts +++ b/data/txhistory/build.gradle.kts @@ -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) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt index 070e0156c3..763331963b 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt @@ -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, express: List): List { +internal fun mergeTxHistoryInfos( + onChain: List, + express: List, + currency: CryptoCurrency, +): List { + val currencyAssetId: ExpressAsset.ID = ExpressAsset.ID(currency) + val isUtxoNetwork: Boolean = currency.network.toBlockchain().isUTXO val onChainByHash = onChain.associateBy { it.txHash } - val matchedHashes = mutableSetOf() + val claimedHashes = mutableSetOf() val result = mutableListOf() + // Phase 1 — deterministic hash match takes priority (payin_hash / payout_hash via ExpressTx.matchHash). + val unmatched = mutableListOf() 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]. @@ -82,4 +132,182 @@ internal fun mergeTangemPay(onChain: List, express: List copy(txInfo = onChain) is ExpressTx.Onramp -> copy(txInfo = onChain) -} \ No newline at end of file +} + +/** + * 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 = when (val source = sourceType) { + is TxInfo.SourceType.Single -> listOf(source.address) + is TxInfo.SourceType.Multiple -> source.addresses +} + +private fun TxInfo.destinationAddresses(): List = 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") \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt index e40f99d1bd..fb59783dc7 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt @@ -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) { diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt index 64be5c447e..23962b5d76 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt @@ -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 { diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt index 9a97a375e7..ce1f4e14bd 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -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)) } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index 7f2e4c4817..578a43fda5 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -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 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) \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt index bc2a4ac207..881cf17e98 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt @@ -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().single() + assertThat(enriched.txInfo?.explorerHash).isEqualTo("near") + val passthrough = result.filterIsInstance().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, + express: List, + currency: CryptoCurrency = FROM_CURRENCY, + ): List = 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 + } } \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt index f400255ebe..367512b77b 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt @@ -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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt index d954ccab3b..c197145ec0 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt @@ -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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt index 6e93235a99..9ae8340a70 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt @@ -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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt index 94573c7e31..3b846403b3 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt @@ -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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt index 00e376fb97..c3dbf469b3 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt @@ -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")) diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt index a3ab3d6850..b7b5d0bf87 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt @@ -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?, ) \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt index ab7800d2df..dae9d68dce 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt @@ -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 } diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt index 069d70f06b..59daa1473e 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt @@ -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?, ) \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index d90ad6861b..f6f0226271 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -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 } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt index 3d3f705a76..956576795b 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt @@ -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 diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt index 3e67bcb269..18679b98f3 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -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( diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt index 7321973cf0..65babf3447 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt @@ -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, ) diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt index 0237261551..dbb5c9aa7f 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt @@ -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,