Updated on 2026-08-14
This commit is contained in:
parent
ec8857a55e
commit
03c5bdd053
25 changed files with 1176 additions and 179 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,43 +1,93 @@
|
|||
package com.tangem.data.txhistory.list
|
||||
|
||||
import com.tangem.blockchain.common.isUTXO
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.OnrampTransaction
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||
import com.tangem.domain.txhistory.model.explorerHash
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
|
||||
* timestamp-DESC timeline.
|
||||
*
|
||||
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
|
||||
* - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
|
||||
* of that hash are collapsed into this row (not emitted standalone).
|
||||
* - unmatched → standalone row (status shown, no on-chain leg). Both in-progress and terminal
|
||||
* (finished/failed) express ops are shown so the user always sees their deals.
|
||||
* Per express op:
|
||||
* 1. deterministic hash match (its [ExpressTx.matchHash] — `payin_hash`/`payout_hash` — against an on-chain hash);
|
||||
* 2. if that fails, a [heuristic fallback][matchesOnChainHeuristically] by direction + address + amount + time,
|
||||
* scoped to the open [currency].
|
||||
*
|
||||
* The heuristic exists because the on-chain hash is not always returned by the provider — the payout leg's hash
|
||||
* is filled by status polling and may never arrive. Without it an incoming swap/onramp would surface twice: once
|
||||
* as the standalone express row and once as its BSDK leg. Matching them collapses the pair into one enriched row.
|
||||
*
|
||||
* Per outcome:
|
||||
* - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es) of that hash are
|
||||
* collapsed into this row (not emitted standalone).
|
||||
* - unmatched → standalone row (status shown, no on-chain leg), except a terminal-but-unsuccessful incoming swap
|
||||
* leg whose on-chain credit never arrived, which is hidden (see [isHiddenWhenUnmatched]) so it does not
|
||||
* surface as a phantom incoming row. A successful (finished) incoming swap, the outgoing / refund side,
|
||||
* and every onramp purchase are always kept so the user still sees their deals.
|
||||
*
|
||||
* On-chain transactions that no express op claimed pass through as [OnChainTx].
|
||||
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
|
||||
*
|
||||
* @param currency the open currency: its [ExpressAsset.ID] scopes the heuristics to legs of this token (network +
|
||||
* contract — replacing the per-leg token/network check, since [TxInfo] carries no network of its own), and its
|
||||
* chain decides whether the pay-in amount tolerates a small UTXO fee/dust variance or must match exactly.
|
||||
*/
|
||||
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
|
||||
internal fun mergeTxHistoryInfos(
|
||||
onChain: List<TxInfo>,
|
||||
express: List<ExpressTx>,
|
||||
currency: CryptoCurrency,
|
||||
): List<TxHistoryInfo> {
|
||||
val currencyAssetId: ExpressAsset.ID = ExpressAsset.ID(currency)
|
||||
val isUtxoNetwork: Boolean = currency.network.toBlockchain().isUTXO
|
||||
val onChainByHash = onChain.associateBy { it.txHash }
|
||||
val matchedHashes = mutableSetOf<String>()
|
||||
val claimedHashes = mutableSetOf<String>()
|
||||
val result = mutableListOf<TxHistoryInfo>()
|
||||
|
||||
// Phase 1 — deterministic hash match takes priority (payin_hash / payout_hash via ExpressTx.matchHash).
|
||||
val unmatched = mutableListOf<ExpressTx>()
|
||||
express.forEach { op ->
|
||||
val matched = op.matchHash?.let(onChainByHash::get)
|
||||
if (matched != null) {
|
||||
result += op.withMatchedOnChain(OnChainTx.BSDK(matched))
|
||||
matchedHashes += matched.txHash
|
||||
val byHash = op.matchHash?.let(onChainByHash::get)
|
||||
if (byHash != null && byHash.txHash !in claimedHashes) {
|
||||
result += op.withMatchedOnChain(OnChainTx.BSDK(byHash))
|
||||
claimedHashes += byHash.txHash
|
||||
} else {
|
||||
result += op
|
||||
unmatched += op
|
||||
}
|
||||
}
|
||||
|
||||
onChain.forEach { tx ->
|
||||
if (tx.txHash !in matchedHashes) {
|
||||
result += OnChainTx.BSDK(tx)
|
||||
// Phase 2 — heuristic fallback for ops whose on-chain hash the provider never returned. On ties, the on-chain
|
||||
// tx closest in time to the deal creation wins (collision resolution). An op that still finds no leg is emitted
|
||||
// standalone, unless it must be hidden (a terminal swap payout that never landed on-chain).
|
||||
unmatched.forEach { op ->
|
||||
val candidate = onChain
|
||||
.filter { tx ->
|
||||
tx.txHash !in claimedHashes && op.matchesOnChainHeuristically(tx, currencyAssetId, isUtxoNetwork)
|
||||
}
|
||||
.minByOrNull { abs(it.timestampInMillis - op.createdAtMillis) }
|
||||
when {
|
||||
candidate != null -> {
|
||||
result += op.withMatchedOnChain(OnChainTx.BSDK(candidate))
|
||||
claimedHashes += candidate.txHash
|
||||
}
|
||||
!op.isHiddenWhenUnmatched() -> result += op
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 — on-chain txs no express op claimed pass through.
|
||||
onChain.forEach { tx ->
|
||||
if (tx.txHash !in claimedHashes) result += OnChainTx.BSDK(tx)
|
||||
}
|
||||
|
||||
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
|
||||
|
|
@ -45,8 +95,8 @@ internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>
|
|||
|
||||
/**
|
||||
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
|
||||
* express overlay. Same rules as [mergeTxHistoryInfos] — matched express ops enrich (carry their
|
||||
* on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through.
|
||||
* express overlay. Hash-based match only — the heuristic fallback is BSDK-specific (it relies on
|
||||
* [TxInfo] direction/addresses/amount) and TangemPay is not wired into the history end-to-end yet.
|
||||
*
|
||||
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
|
||||
* express op's [ExpressTx.matchHash].
|
||||
|
|
@ -83,3 +133,181 @@ private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (
|
|||
is ExpressTx.Swap -> copy(txInfo = onChain)
|
||||
is ExpressTx.Onramp -> copy(txInfo = onChain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an express op that matched no on-chain leg must be hidden instead of surfacing standalone.
|
||||
*
|
||||
* Only a *terminal-but-unsuccessful* incoming (payout) leg of a swap is hidden — one that ended in failure / refund /
|
||||
* expiry without ever landing on-chain (or landing somewhere we do not page); a standalone "You receive" row there
|
||||
* would be a phantom. A *successful* (finished) incoming swap is always kept even unmatched: the credit did arrive
|
||||
* and must be shown whether or not we could join it to an on-chain tx — the deciding case for the index-table
|
||||
* backbone, where there is no on-chain row to fall back to. The outgoing / refund side of a swap
|
||||
* ([ExpressTx.Swap.isOutgoing], viewed on the from-token screen — where a refund lands) and every onramp purchase are
|
||||
* always kept too.
|
||||
*/
|
||||
private fun ExpressTx.isHiddenWhenUnmatched(): Boolean = when (this) {
|
||||
is ExpressTx.Swap -> isTerminal && !tx.status.isFinished && !isOutgoing
|
||||
is ExpressTx.Onramp -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the on-chain [onChainTx] look like the leg of this express op, when their hashes did not line up?
|
||||
*
|
||||
* Dispatches by op kind and viewed direction. The matched leg's asset must be the open currency ([currencyAssetId]) —
|
||||
* this replaces the per-leg `T.network == E.*_network && T.contract == E.*_contract` check ([TxInfo] itself carries
|
||||
* no network):
|
||||
* - outgoing swap → the pay-in leg it sent;
|
||||
* - refunded outgoing swap → the incoming refund of the `from` asset that lands on the same screen;
|
||||
* - incoming swap → the payout leg it received (the case the missing payout hash breaks);
|
||||
* - onramp → the payout leg it received.
|
||||
*/
|
||||
private fun ExpressTx.matchesOnChainHeuristically(
|
||||
onChainTx: TxInfo,
|
||||
currencyAssetId: ExpressAsset.ID,
|
||||
isUtxoNetwork: Boolean,
|
||||
): Boolean = when (this) {
|
||||
is ExpressTx.Swap -> when {
|
||||
isOutgoing && !onChainTx.isOutgoing && tx.status == ExpressExchangeStatus.Refunded ->
|
||||
tx.matchesRefund(onChainTx, currencyAssetId)
|
||||
isOutgoing -> tx.matchesOutgoingPayin(onChainTx, currencyAssetId, isUtxoNetwork)
|
||||
else -> tx.matchesIncomingPayout(onChainTx, currencyAssetId)
|
||||
}
|
||||
is ExpressTx.Onramp -> tx.matchesOnrampPayout(onChainTx, currencyAssetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing (pay-in) leg: the user sent the `from` asset to the provider deposit address. That address
|
||||
* ([ExchangeTransaction.payinAddress]) is a per-deal discriminator; the sender and amount confirm it. The amount is
|
||||
* matched exactly, allowing the small UTXO fee/dust variance ([OUTGOING_AMOUNT_TOLERANCE]) only on UTXO chains.
|
||||
*/
|
||||
private fun ExchangeTransaction.matchesOutgoingPayin(
|
||||
onChainTx: TxInfo,
|
||||
currencyAssetId: ExpressAsset.ID,
|
||||
isUtxoNetwork: Boolean,
|
||||
): Boolean {
|
||||
if (!onChainTx.isOutgoing) return false
|
||||
if (!fromAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||
if (onChainTx.destinationAddresses().none { it.matchesAddress(payinAddress) }) return false
|
||||
if (!isSentFromUser(onChainTx)) return false
|
||||
val tolerance = if (isUtxoNetwork) OUTGOING_AMOUNT_TOLERANCE else EXACT_AMOUNT_TOLERANCE
|
||||
return onChainTx.amount.matchesWithin(fromAmount, tolerance)
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming (payout) leg: the `to` asset landed on the user's payout address. This is where the missing
|
||||
* payout hash bites, so address + amount + a 24h window carry the match; the sender must not be the user
|
||||
* (self-transfer exclusion). Amount matches the actual settled value exactly when known
|
||||
* ([ExchangeTransaction.toActualAmount]), otherwise the expected value within the slippage tolerance.
|
||||
*/
|
||||
private fun ExchangeTransaction.matchesIncomingPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||
if (onChainTx.isOutgoing) return false
|
||||
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
|
||||
if (isSentFromUser(onChainTx)) return false // self-transfer, not a provider payout
|
||||
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||
return false
|
||||
}
|
||||
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund leg: a refunded swap returns the `from` asset to the user as an incoming tx on the from-token
|
||||
* screen. Matched by direction + status + amount (within a wider tolerance for fees/volatility) + a window
|
||||
* that extends to `updatedAt + 24h`, since the refund is credited after the deal was last updated. When the refund
|
||||
* asset is known ([ExchangeTransaction.refundAssetId]) it must be the open currency.
|
||||
*/
|
||||
private fun ExchangeTransaction.matchesRefund(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||
if (onChainTx.isOutgoing) return false
|
||||
val refundId = refundAssetId
|
||||
if (refundId != null && !refundId.matchesAssetId(currencyAssetId)) return false
|
||||
if (isSentFromUser(onChainTx)) return false // refund comes from the provider, not the user
|
||||
if (!isWithinWindow(from = createdAtMillis, upperBase = updatedAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||
return false
|
||||
}
|
||||
return onChainTx.amount.matchesWithin(fromAmount, REFUND_AMOUNT_TOLERANCE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Onramp payout leg: bought crypto landed on the user's payout address. No `from` address to exclude
|
||||
* (the source is fiat), so address + amount + the 24h window carry the match.
|
||||
*/
|
||||
private fun OnrampTransaction.matchesOnrampPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||
if (onChainTx.isOutgoing) return false
|
||||
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
|
||||
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||
return false
|
||||
}
|
||||
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount match for an incoming leg: the [actual] settled amount matched exactly when known, otherwise the
|
||||
* [expected] amount within [INCOMING_SLIPPAGE_TOLERANCE]; no match when neither is known.
|
||||
*/
|
||||
private fun matchesIncomingAmount(txAmount: BigDecimal, actual: BigDecimal?, expected: BigDecimal?): Boolean = when {
|
||||
actual != null -> txAmount.matchesWithin(actual, EXACT_AMOUNT_TOLERANCE)
|
||||
expected != null -> txAmount.matchesWithin(expected, INCOMING_SLIPPAGE_TOLERANCE)
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** Whether this asset id is the open currency's asset (network id + contract), tolerant of EVM contract casing. */
|
||||
private fun ExpressAsset.ID.matchesAssetId(other: ExpressAsset.ID): Boolean =
|
||||
networkId.equals(other.networkId, ignoreCase = true) &&
|
||||
contractAddress.equals(other.contractAddress, ignoreCase = true)
|
||||
|
||||
/** Relative-difference amount comparison: `|this - target| / |target| <= tolerance`. */
|
||||
private fun BigDecimal.matchesWithin(target: BigDecimal, tolerance: BigDecimal): Boolean {
|
||||
if (target.signum() == 0) return signum() == 0
|
||||
val relativeDiff = (this - target).abs().divide(target.abs(), AMOUNT_SCALE, RoundingMode.HALF_UP)
|
||||
return relativeDiff <= tolerance
|
||||
}
|
||||
|
||||
/** Inclusive time window `[from, upperBase + 24h]`. */
|
||||
private fun isWithinWindow(from: Long, upperBase: Long, ts: Long): Boolean {
|
||||
val upperBound = upperBase + TIME_WINDOW_MILLIS
|
||||
return ts in from..upperBound
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the on-chain tx was sent from the user's own `from` address — i.e. a self-transfer, not a
|
||||
* provider payout/refund.
|
||||
*/
|
||||
private fun ExchangeTransaction.isSentFromUser(onChainTx: TxInfo): Boolean {
|
||||
val from = fromAddress
|
||||
return onChainTx.sourceAddresses().any { it.matchesAddress(from) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Address equality tolerant of case: EVM addresses differ only by checksum casing, and other chains'
|
||||
* distinct addresses won't collide case-insensitively in practice.
|
||||
*/
|
||||
private fun String.matchesAddress(other: String): Boolean = equals(other, ignoreCase = true)
|
||||
|
||||
private fun TxInfo.sourceAddresses(): List<String> = when (val source = sourceType) {
|
||||
is TxInfo.SourceType.Single -> listOf(source.address)
|
||||
is TxInfo.SourceType.Multiple -> source.addresses
|
||||
}
|
||||
|
||||
private fun TxInfo.destinationAddresses(): List<String> = when (val destination = destinationType) {
|
||||
is TxInfo.DestinationType.Single -> listOf(destination.addressType.address)
|
||||
is TxInfo.DestinationType.Multiple -> destination.addressTypes.map { it.address }
|
||||
}
|
||||
|
||||
/** Scale used when computing relative amount differences. */
|
||||
private const val AMOUNT_SCALE = 10
|
||||
|
||||
/** 24h credit/refund buffer for the incoming and refund time windows. */
|
||||
private const val TIME_WINDOW_MILLIS = 86_400_000L
|
||||
|
||||
/** 0% — exact match, used for the pay-in amount off UTXO chains and for the confirmed actual settled amount. */
|
||||
private val EXACT_AMOUNT_TOLERANCE = BigDecimal.ZERO
|
||||
|
||||
/** 0.1% — absorbs UTXO fee/dust variance on the pay-in amount (applied only on UTXO chains). */
|
||||
private val OUTGOING_AMOUNT_TOLERANCE = BigDecimal("0.001")
|
||||
|
||||
/** 5% — payout slippage vs the expected `to` amount (used only when no actual amount is known). */
|
||||
private val INCOMING_SLIPPAGE_TOLERANCE = BigDecimal("0.05")
|
||||
|
||||
/** 15% — fees + volatility on the refunded amount vs the original `from` amount. */
|
||||
private val REFUND_AMOUNT_TOLERANCE = BigDecimal("0.15")
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.tangem.data.txhistory.repository.factory.toRefundAssetId
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressOnrampStatus
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.express.models.OnrampTransaction
|
||||
import com.tangem.domain.express.models.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
|
|
@ -17,6 +12,7 @@ import com.tangem.domain.txhistory.model.ExpressTx
|
|||
import com.tangem.utils.converter.Converter
|
||||
import org.joda.time.DateTime
|
||||
import java.math.BigDecimal
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
|
||||
/**
|
||||
* Maps an exchange entity into a swap [ExpressTx.Swap].
|
||||
|
|
@ -37,8 +33,9 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
|
|||
val entity: ExpressExchangeEntity,
|
||||
val provider: ExpressProvider?,
|
||||
val isOutgoing: Boolean,
|
||||
val fromCurrency: CryptoCurrency? = null,
|
||||
val toCurrency: CryptoCurrency? = null,
|
||||
val fromCurrency: CryptoCurrency?,
|
||||
val toCurrency: CryptoCurrency?,
|
||||
val refundCurrency: CryptoCurrency?,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +43,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
|
||||
override fun convert(value: Input): ExpressTx.Onramp {
|
||||
val entity = value.entity
|
||||
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals)
|
||||
return ExpressTx.Onramp(
|
||||
tx = OnrampTransaction(
|
||||
txId = entity.txId,
|
||||
|
|
@ -58,16 +56,18 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
currencySymbol = entity.fromCurrencyCode,
|
||||
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
||||
decimals = entity.fromPrecision,
|
||||
type = AmountType.FiatType(code = entity.fromCurrencyCode),
|
||||
type = AmountType.FiatType(code = value.country?.defaultCurrency?.unit ?: entity.fromCurrencyCode),
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
|
||||
amount = toActualAmount,
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
country = value.country,
|
||||
externalTxUrl = entity.externalTxUrl,
|
||||
toAmount = entity.to.amount?.toScaledBigDecimal(entity.to.decimals),
|
||||
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
|
||||
),
|
||||
txInfo = null,
|
||||
)
|
||||
|
|
@ -83,6 +83,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
|
||||
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
|
||||
val entity = value.entity
|
||||
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals)
|
||||
return ExchangeTransaction(
|
||||
txId = entity.txId,
|
||||
status = ExpressExchangeStatus.fromRaw(entity.status),
|
||||
|
|
@ -100,11 +101,19 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
|
|||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
|
||||
amount = toActualAmount,
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
externalTxUrl = entity.externalTxUrl,
|
||||
payinAddress = entity.payinAddress,
|
||||
updatedAtMillis = parseIsoMillis(entity.updatedAt),
|
||||
refundAssetId = entity.toRefundAssetId(),
|
||||
refundCurrency = value.refundCurrency,
|
||||
|
||||
fromAmount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
|
||||
toAmount = entity.to.amount.toScaledBigDecimal(entity.to.decimals),
|
||||
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
|
|||
(outgoingSwaps + incomingSwaps).forEach { entity ->
|
||||
add(entity.from.toAssetId())
|
||||
add(entity.to.toAssetId())
|
||||
entity.toRefundAssetId()?.let { add(it) }
|
||||
}
|
||||
onramps.forEach { entity -> add(entity.to.toAssetId()) }
|
||||
}
|
||||
|
|
@ -125,5 +126,15 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
|
|||
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
||||
internal fun ExpressExchangeEntity.toRefundAssetId(): ExpressAsset.ID? {
|
||||
val refundNetwork = refundNetwork
|
||||
val refundContractAddress = refundContractAddress
|
||||
return if (refundNetwork != null && refundContractAddress != null) {
|
||||
ExpressAsset.ID(refundNetwork, refundContractAddress)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
|
@ -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,14 +24,18 @@ import java.math.BigDecimal
|
|||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TxHistoryInfoMergerTest {
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class DeterministicHashMatch {
|
||||
|
||||
@Test
|
||||
fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() {
|
||||
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))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express)
|
||||
val result = merge(onChain, express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
|
|
@ -37,7 +50,7 @@ internal class TxHistoryInfoMergerTest {
|
|||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
||||
val result = merge(onChain = emptyList(), express = express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
|
|
@ -45,12 +58,14 @@ internal class TxHistoryInfoMergerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() {
|
||||
// Arrange
|
||||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
|
||||
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 = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
||||
val result = merge(onChain = emptyList(), express = express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
|
|
@ -65,7 +80,7 @@ internal class TxHistoryInfoMergerTest {
|
|||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express = emptyList())
|
||||
val result = merge(onChain, express = emptyList())
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
|
|
@ -81,29 +96,570 @@ internal class TxHistoryInfoMergerTest {
|
|||
)
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express)
|
||||
val result = merge(onChain, express)
|
||||
|
||||
// Assert
|
||||
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class HeuristicFallbackMatch {
|
||||
|
||||
@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"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
val row = result.single()
|
||||
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||
assertThat((row as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming swap WHEN on-chain sender is the user THEN self-transfer not matched and both rows kept`() {
|
||||
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "credit",
|
||||
timestamp = 200,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "mySource",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Waiting,
|
||||
isOutgoing = false,
|
||||
createdAtMillis = 100,
|
||||
fromAddress = "mySource",
|
||||
payoutAddress = "myPayout",
|
||||
toAmount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming swap WHEN on-chain tx is outside the 24h window THEN not matched`() {
|
||||
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "credit",
|
||||
timestamp = 100 + DAY_MILLIS + 1,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Waiting,
|
||||
isOutgoing = false,
|
||||
createdAtMillis = 100,
|
||||
payoutAddress = "myPayout",
|
||||
toAmount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming swap with expected amount only WHEN on-chain amount is beyond slippage THEN not matched`() {
|
||||
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "credit",
|
||||
timestamp = 200,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.01"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Waiting,
|
||||
isOutgoing = false,
|
||||
createdAtMillis = 100,
|
||||
payoutAddress = "myPayout",
|
||||
toAmount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing swap without payin hash WHEN on-chain pay-in matches deposit address and amount THEN matched`() {
|
||||
// Arrange
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "payin",
|
||||
timestamp = 120,
|
||||
isOutgoing = true,
|
||||
sourceAddress = "mySource",
|
||||
destinationAddress = "providerDeposit",
|
||||
amount = BigDecimal("1.5"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Confirming,
|
||||
isOutgoing = true,
|
||||
createdAtMillis = 100,
|
||||
fromAddress = "mySource",
|
||||
payinAddress = "providerDeposit",
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("payin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN refunded swap WHEN incoming refund is within window and amount tolerance THEN matched`() {
|
||||
// Arrange: refund returns the from-asset to the user on the from-token screen.
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "refund",
|
||||
timestamp = 2000,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "mySource",
|
||||
amount = BigDecimal("1.4"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Refunded,
|
||||
isOutgoing = true,
|
||||
createdAtMillis = 100,
|
||||
updatedAtMillis = 1000,
|
||||
fromAddress = "mySource",
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("refund")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp without payout hash WHEN on-chain credit matches address amount time THEN matched`() {
|
||||
// Arrange
|
||||
val onChain = listOf(
|
||||
createTxInfo(
|
||||
txHash = "credit",
|
||||
timestamp = 200,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
val express = listOf(
|
||||
createOnramp(
|
||||
matchHash = null,
|
||||
status = ExpressOnrampStatus.Finished,
|
||||
createdAtMillis = 100,
|
||||
payoutAddress = "myPayout",
|
||||
toAmount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two on-chain candidates WHEN incoming swap matches both THEN the closest in time is claimed and the other passes through`() {
|
||||
// Arrange
|
||||
val near = createTxInfo(
|
||||
txHash = "near",
|
||||
timestamp = 150,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.001"),
|
||||
)
|
||||
val far = createTxInfo(
|
||||
txHash = "far",
|
||||
timestamp = 500,
|
||||
isOutgoing = false,
|
||||
sourceAddress = "provider",
|
||||
destinationAddress = "myPayout",
|
||||
amount = BigDecimal("0.001"),
|
||||
)
|
||||
val express = listOf(
|
||||
createSwap(
|
||||
matchHash = null,
|
||||
status = ExpressExchangeStatus.Finished,
|
||||
isOutgoing = false,
|
||||
createdAtMillis = 100,
|
||||
payoutAddress = "myPayout",
|
||||
toAmount = BigDecimal("0.001"),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = merge(onChain = listOf(near, far), express = express, currency = TO_CURRENCY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(2)
|
||||
val enriched = result.filterIsInstance<ExpressTx>().single()
|
||||
assertThat(enriched.txInfo?.explorerHash).isEqualTo("near")
|
||||
val passthrough = result.filterIsInstance<OnChainTx.BSDK>().single()
|
||||
assertThat(passthrough.txInfo.txHash).isEqualTo("far")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class CurrencyAndAmountScoping {
|
||||
|
||||
@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"),
|
||||
),
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class UnmatchedTerminalHiding {
|
||||
|
||||
@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),
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun merge(
|
||||
onChain: List<TxInfo>,
|
||||
express: List<ExpressTx>,
|
||||
currency: CryptoCurrency = FROM_CURRENCY,
|
||||
): List<TxHistoryInfo> = mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,20 +238,30 @@ 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(
|
||||
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,
|
||||
payoutAddress = "payout-addr",
|
||||
fromFiat = Amount(
|
||||
currencySymbol = "SEK",
|
||||
value = BigDecimal("100"),
|
||||
|
|
@ -263,6 +273,10 @@ internal class ExpressTxToTransactionItemUMConverterTest {
|
|||
amount = toAmount,
|
||||
decimals = 8,
|
||||
),
|
||||
externalTxUrl = null,
|
||||
country = null,
|
||||
toAmount = toAmount,
|
||||
toActualAmount = null,
|
||||
),
|
||||
txInfo = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue