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,
|
val createdAt: String,
|
||||||
|
|
||||||
/** Transaction last-update timestamp in ISO-8601 format */
|
/** Transaction last-update timestamp in ISO-8601 format */
|
||||||
// todo txHistory uncomment
|
|
||||||
/*
|
|
||||||
@Json(name = "updatedAt")
|
@Json(name = "updatedAt")
|
||||||
val updatedAt: String,
|
val updatedAt: String,
|
||||||
*/
|
|
||||||
|
|
||||||
/** Pay-in expiration timestamp in ISO-8601 format */
|
/** Pay-in expiration timestamp in ISO-8601 format */
|
||||||
@Json(name = "payTill")
|
@Json(name = "payTill")
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,8 @@ data class OnrampItemResponse(
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
|
|
||||||
/** Transaction last-update timestamp in ISO-8601 format */
|
/** Transaction last-update timestamp in ISO-8601 format */
|
||||||
// todo txHistory uncomment
|
|
||||||
/*
|
|
||||||
@Json(name = "updatedAt")
|
@Json(name = "updatedAt")
|
||||||
val updatedAt: String,
|
val updatedAt: String,
|
||||||
*/
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region fromAsset (fiat) info
|
// region fromAsset (fiat) info
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ fun ExchangeItemResponse.toEntity(): ExpressExchangeEntity? {
|
||||||
refundNetwork = refundNetwork,
|
refundNetwork = refundNetwork,
|
||||||
refundContractAddress = refundContractAddress,
|
refundContractAddress = refundContractAddress,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment
|
updatedAt = updatedAt,
|
||||||
payTill = payTill,
|
payTill = payTill,
|
||||||
averageDuration = averageDuration,
|
averageDuration = averageDuration,
|
||||||
from = ExpressExchangeEntity.AssetEmbedded(
|
from = ExpressExchangeEntity.AssetEmbedded(
|
||||||
|
|
@ -61,7 +61,7 @@ fun OnrampItemResponse.toEntity(): ExpressOnrampEntity {
|
||||||
externalTxUrl = externalTxUrl,
|
externalTxUrl = externalTxUrl,
|
||||||
payoutHash = payoutHash,
|
payoutHash = payoutHash,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = ""/*updatedAt*/, // todo txHistory uncomment,
|
updatedAt = updatedAt,
|
||||||
fromCurrencyCode = fromCurrencyCode,
|
fromCurrencyCode = fromCurrencyCode,
|
||||||
fromAmount = fromAmount,
|
fromAmount = fromAmount,
|
||||||
fromPrecision = fromPrecision,
|
fromPrecision = fromPrecision,
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ internal class ExpressHistoryConverterTest {
|
||||||
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
|
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
|
||||||
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
|
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
|
||||||
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
|
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
|
||||||
// todo txHistory uncomment
|
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
|
||||||
// Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
|
|
||||||
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
|
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
|
||||||
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
|
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
|
||||||
}
|
}
|
||||||
|
|
@ -233,8 +232,7 @@ internal class ExpressHistoryConverterTest {
|
||||||
refundNetwork = refundNetwork,
|
refundNetwork = refundNetwork,
|
||||||
refundContractAddress = refundContractAddress,
|
refundContractAddress = refundContractAddress,
|
||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
// todo txHistory uncomment
|
updatedAt = "2026-06-01T00:05:00Z",
|
||||||
// updatedAt = "2026-06-01T00:05:00Z",
|
|
||||||
payTill = payTill,
|
payTill = payTill,
|
||||||
averageDuration = averageDuration,
|
averageDuration = averageDuration,
|
||||||
fromContractAddress = "0xfromContract",
|
fromContractAddress = "0xfromContract",
|
||||||
|
|
@ -266,8 +264,7 @@ internal class ExpressHistoryConverterTest {
|
||||||
externalTxUrl = externalTxUrl,
|
externalTxUrl = externalTxUrl,
|
||||||
payoutHash = payoutHash,
|
payoutHash = payoutHash,
|
||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
// todo txHistory uncomment
|
updatedAt = "2026-06-01T00:05:00Z",
|
||||||
// updatedAt = "2026-06-01T00:05:00Z",
|
|
||||||
fromCurrencyCode = "USD",
|
fromCurrencyCode = "USD",
|
||||||
fromAmount = "100.0",
|
fromAmount = "100.0",
|
||||||
fromPrecision = 2,
|
fromPrecision = 2,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,23 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
// region Kotlin
|
implementation(projects.domain.legacy)
|
||||||
api(deps.kotlin.coroutines)
|
implementation(projects.domain.common)
|
||||||
// endregion
|
implementation(projects.domain.walletManager)
|
||||||
|
implementation(projects.domain.models)
|
||||||
|
implementation(projects.domain.tokens.models)
|
||||||
|
implementation(projects.domain.txhistory)
|
||||||
|
implementation(projects.domain.txhistory.models)
|
||||||
|
implementation(projects.domain.express)
|
||||||
|
implementation(projects.domain.express.models)
|
||||||
|
implementation(projects.domain.wallets.models)
|
||||||
|
implementation(projects.domain.wallets)
|
||||||
|
implementation(projects.domain.onramp)
|
||||||
|
implementation(projects.domain.onramp.models)
|
||||||
|
implementation(projects.domain.account)
|
||||||
|
implementation(projects.domain.account.status)
|
||||||
|
implementation(projects.domain.visa)
|
||||||
|
implementation(projects.domain.visa.models)
|
||||||
|
|
||||||
// region Other libraries
|
// region Other libraries
|
||||||
implementation(deps.androidx.annotation)
|
implementation(deps.androidx.annotation)
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,102 @@
|
||||||
package com.tangem.data.txhistory.list
|
package com.tangem.data.txhistory.list
|
||||||
|
|
||||||
|
import com.tangem.blockchain.common.isUTXO
|
||||||
|
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||||
|
import com.tangem.domain.express.models.ExchangeTransaction
|
||||||
|
import com.tangem.domain.express.models.ExpressAsset
|
||||||
|
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||||
|
import com.tangem.domain.express.models.OnrampTransaction
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.TxInfo
|
import com.tangem.domain.models.network.TxInfo
|
||||||
import com.tangem.domain.txhistory.model.ExpressTx
|
import com.tangem.domain.txhistory.model.ExpressTx
|
||||||
import com.tangem.domain.txhistory.model.OnChainTx
|
import com.tangem.domain.txhistory.model.OnChainTx
|
||||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||||
import com.tangem.domain.txhistory.model.explorerHash
|
import com.tangem.domain.txhistory.model.explorerHash
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
|
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
|
||||||
* timestamp-DESC timeline.
|
* timestamp-DESC timeline.
|
||||||
*
|
*
|
||||||
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
|
* Per express op:
|
||||||
* - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
|
* 1. deterministic hash match (its [ExpressTx.matchHash] — `payin_hash`/`payout_hash` — against an on-chain hash);
|
||||||
* of that hash are collapsed into this row (not emitted standalone).
|
* 2. if that fails, a [heuristic fallback][matchesOnChainHeuristically] by direction + address + amount + time,
|
||||||
* - unmatched → standalone row (status shown, no on-chain leg). Both in-progress and terminal
|
* scoped to the open [currency].
|
||||||
* (finished/failed) express ops are shown so the user always sees their deals.
|
*
|
||||||
|
* The heuristic exists because the on-chain hash is not always returned by the provider — the payout leg's hash
|
||||||
|
* is filled by status polling and may never arrive. Without it an incoming swap/onramp would surface twice: once
|
||||||
|
* as the standalone express row and once as its BSDK leg. Matching them collapses the pair into one enriched row.
|
||||||
|
*
|
||||||
|
* Per outcome:
|
||||||
|
* - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es) of that hash are
|
||||||
|
* collapsed into this row (not emitted standalone).
|
||||||
|
* - unmatched → standalone row (status shown, no on-chain leg), except a terminal-but-unsuccessful incoming swap
|
||||||
|
* leg whose on-chain credit never arrived, which is hidden (see [isHiddenWhenUnmatched]) so it does not
|
||||||
|
* surface as a phantom incoming row. A successful (finished) incoming swap, the outgoing / refund side,
|
||||||
|
* and every onramp purchase are always kept so the user still sees their deals.
|
||||||
*
|
*
|
||||||
* On-chain transactions that no express op claimed pass through as [OnChainTx].
|
* On-chain transactions that no express op claimed pass through as [OnChainTx].
|
||||||
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
|
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
|
||||||
|
*
|
||||||
|
* @param currency the open currency: its [ExpressAsset.ID] scopes the heuristics to legs of this token (network +
|
||||||
|
* contract — replacing the per-leg token/network check, since [TxInfo] carries no network of its own), and its
|
||||||
|
* chain decides whether the pay-in amount tolerates a small UTXO fee/dust variance or must match exactly.
|
||||||
*/
|
*/
|
||||||
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
|
internal fun mergeTxHistoryInfos(
|
||||||
|
onChain: List<TxInfo>,
|
||||||
|
express: List<ExpressTx>,
|
||||||
|
currency: CryptoCurrency,
|
||||||
|
): List<TxHistoryInfo> {
|
||||||
|
val currencyAssetId: ExpressAsset.ID = ExpressAsset.ID(currency)
|
||||||
|
val isUtxoNetwork: Boolean = currency.network.toBlockchain().isUTXO
|
||||||
val onChainByHash = onChain.associateBy { it.txHash }
|
val onChainByHash = onChain.associateBy { it.txHash }
|
||||||
val matchedHashes = mutableSetOf<String>()
|
val claimedHashes = mutableSetOf<String>()
|
||||||
val result = mutableListOf<TxHistoryInfo>()
|
val result = mutableListOf<TxHistoryInfo>()
|
||||||
|
|
||||||
|
// Phase 1 — deterministic hash match takes priority (payin_hash / payout_hash via ExpressTx.matchHash).
|
||||||
|
val unmatched = mutableListOf<ExpressTx>()
|
||||||
express.forEach { op ->
|
express.forEach { op ->
|
||||||
val matched = op.matchHash?.let(onChainByHash::get)
|
val byHash = op.matchHash?.let(onChainByHash::get)
|
||||||
if (matched != null) {
|
if (byHash != null && byHash.txHash !in claimedHashes) {
|
||||||
result += op.withMatchedOnChain(OnChainTx.BSDK(matched))
|
result += op.withMatchedOnChain(OnChainTx.BSDK(byHash))
|
||||||
matchedHashes += matched.txHash
|
claimedHashes += byHash.txHash
|
||||||
} else {
|
} else {
|
||||||
result += op
|
unmatched += op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChain.forEach { tx ->
|
// Phase 2 — heuristic fallback for ops whose on-chain hash the provider never returned. On ties, the on-chain
|
||||||
if (tx.txHash !in matchedHashes) {
|
// tx closest in time to the deal creation wins (collision resolution). An op that still finds no leg is emitted
|
||||||
result += OnChainTx.BSDK(tx)
|
// standalone, unless it must be hidden (a terminal swap payout that never landed on-chain).
|
||||||
|
unmatched.forEach { op ->
|
||||||
|
val candidate = onChain
|
||||||
|
.filter { tx ->
|
||||||
|
tx.txHash !in claimedHashes && op.matchesOnChainHeuristically(tx, currencyAssetId, isUtxoNetwork)
|
||||||
|
}
|
||||||
|
.minByOrNull { abs(it.timestampInMillis - op.createdAtMillis) }
|
||||||
|
when {
|
||||||
|
candidate != null -> {
|
||||||
|
result += op.withMatchedOnChain(OnChainTx.BSDK(candidate))
|
||||||
|
claimedHashes += candidate.txHash
|
||||||
|
}
|
||||||
|
!op.isHiddenWhenUnmatched() -> result += op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3 — on-chain txs no express op claimed pass through.
|
||||||
|
onChain.forEach { tx ->
|
||||||
|
if (tx.txHash !in claimedHashes) result += OnChainTx.BSDK(tx)
|
||||||
|
}
|
||||||
|
|
||||||
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
|
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
|
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
|
||||||
* express overlay. Same rules as [mergeTxHistoryInfos] — matched express ops enrich (carry their
|
* express overlay. Hash-based match only — the heuristic fallback is BSDK-specific (it relies on
|
||||||
* on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through.
|
* [TxInfo] direction/addresses/amount) and TangemPay is not wired into the history end-to-end yet.
|
||||||
*
|
*
|
||||||
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
|
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
|
||||||
* express op's [ExpressTx.matchHash].
|
* express op's [ExpressTx.matchHash].
|
||||||
|
|
@ -82,4 +132,182 @@ internal fun mergeTangemPay(onChain: List<OnChainTx.TangemPay>, express: List<Ex
|
||||||
private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) {
|
private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) {
|
||||||
is ExpressTx.Swap -> copy(txInfo = onChain)
|
is ExpressTx.Swap -> copy(txInfo = onChain)
|
||||||
is ExpressTx.Onramp -> copy(txInfo = onChain)
|
is ExpressTx.Onramp -> copy(txInfo = onChain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an express op that matched no on-chain leg must be hidden instead of surfacing standalone.
|
||||||
|
*
|
||||||
|
* Only a *terminal-but-unsuccessful* incoming (payout) leg of a swap is hidden — one that ended in failure / refund /
|
||||||
|
* expiry without ever landing on-chain (or landing somewhere we do not page); a standalone "You receive" row there
|
||||||
|
* would be a phantom. A *successful* (finished) incoming swap is always kept even unmatched: the credit did arrive
|
||||||
|
* and must be shown whether or not we could join it to an on-chain tx — the deciding case for the index-table
|
||||||
|
* backbone, where there is no on-chain row to fall back to. The outgoing / refund side of a swap
|
||||||
|
* ([ExpressTx.Swap.isOutgoing], viewed on the from-token screen — where a refund lands) and every onramp purchase are
|
||||||
|
* always kept too.
|
||||||
|
*/
|
||||||
|
private fun ExpressTx.isHiddenWhenUnmatched(): Boolean = when (this) {
|
||||||
|
is ExpressTx.Swap -> isTerminal && !tx.status.isFinished && !isOutgoing
|
||||||
|
is ExpressTx.Onramp -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the on-chain [onChainTx] look like the leg of this express op, when their hashes did not line up?
|
||||||
|
*
|
||||||
|
* Dispatches by op kind and viewed direction. The matched leg's asset must be the open currency ([currencyAssetId]) —
|
||||||
|
* this replaces the per-leg `T.network == E.*_network && T.contract == E.*_contract` check ([TxInfo] itself carries
|
||||||
|
* no network):
|
||||||
|
* - outgoing swap → the pay-in leg it sent;
|
||||||
|
* - refunded outgoing swap → the incoming refund of the `from` asset that lands on the same screen;
|
||||||
|
* - incoming swap → the payout leg it received (the case the missing payout hash breaks);
|
||||||
|
* - onramp → the payout leg it received.
|
||||||
|
*/
|
||||||
|
private fun ExpressTx.matchesOnChainHeuristically(
|
||||||
|
onChainTx: TxInfo,
|
||||||
|
currencyAssetId: ExpressAsset.ID,
|
||||||
|
isUtxoNetwork: Boolean,
|
||||||
|
): Boolean = when (this) {
|
||||||
|
is ExpressTx.Swap -> when {
|
||||||
|
isOutgoing && !onChainTx.isOutgoing && tx.status == ExpressExchangeStatus.Refunded ->
|
||||||
|
tx.matchesRefund(onChainTx, currencyAssetId)
|
||||||
|
isOutgoing -> tx.matchesOutgoingPayin(onChainTx, currencyAssetId, isUtxoNetwork)
|
||||||
|
else -> tx.matchesIncomingPayout(onChainTx, currencyAssetId)
|
||||||
|
}
|
||||||
|
is ExpressTx.Onramp -> tx.matchesOnrampPayout(onChainTx, currencyAssetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing (pay-in) leg: the user sent the `from` asset to the provider deposit address. That address
|
||||||
|
* ([ExchangeTransaction.payinAddress]) is a per-deal discriminator; the sender and amount confirm it. The amount is
|
||||||
|
* matched exactly, allowing the small UTXO fee/dust variance ([OUTGOING_AMOUNT_TOLERANCE]) only on UTXO chains.
|
||||||
|
*/
|
||||||
|
private fun ExchangeTransaction.matchesOutgoingPayin(
|
||||||
|
onChainTx: TxInfo,
|
||||||
|
currencyAssetId: ExpressAsset.ID,
|
||||||
|
isUtxoNetwork: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
if (!onChainTx.isOutgoing) return false
|
||||||
|
if (!fromAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||||
|
if (onChainTx.destinationAddresses().none { it.matchesAddress(payinAddress) }) return false
|
||||||
|
if (!isSentFromUser(onChainTx)) return false
|
||||||
|
val tolerance = if (isUtxoNetwork) OUTGOING_AMOUNT_TOLERANCE else EXACT_AMOUNT_TOLERANCE
|
||||||
|
return onChainTx.amount.matchesWithin(fromAmount, tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incoming (payout) leg: the `to` asset landed on the user's payout address. This is where the missing
|
||||||
|
* payout hash bites, so address + amount + a 24h window carry the match; the sender must not be the user
|
||||||
|
* (self-transfer exclusion). Amount matches the actual settled value exactly when known
|
||||||
|
* ([ExchangeTransaction.toActualAmount]), otherwise the expected value within the slippage tolerance.
|
||||||
|
*/
|
||||||
|
private fun ExchangeTransaction.matchesIncomingPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||||
|
if (onChainTx.isOutgoing) return false
|
||||||
|
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||||
|
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
|
||||||
|
if (isSentFromUser(onChainTx)) return false // self-transfer, not a provider payout
|
||||||
|
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund leg: a refunded swap returns the `from` asset to the user as an incoming tx on the from-token
|
||||||
|
* screen. Matched by direction + status + amount (within a wider tolerance for fees/volatility) + a window
|
||||||
|
* that extends to `updatedAt + 24h`, since the refund is credited after the deal was last updated. When the refund
|
||||||
|
* asset is known ([ExchangeTransaction.refundAssetId]) it must be the open currency.
|
||||||
|
*/
|
||||||
|
private fun ExchangeTransaction.matchesRefund(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||||
|
if (onChainTx.isOutgoing) return false
|
||||||
|
val refundId = refundAssetId
|
||||||
|
if (refundId != null && !refundId.matchesAssetId(currencyAssetId)) return false
|
||||||
|
if (isSentFromUser(onChainTx)) return false // refund comes from the provider, not the user
|
||||||
|
if (!isWithinWindow(from = createdAtMillis, upperBase = updatedAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return onChainTx.amount.matchesWithin(fromAmount, REFUND_AMOUNT_TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onramp payout leg: bought crypto landed on the user's payout address. No `from` address to exclude
|
||||||
|
* (the source is fiat), so address + amount + the 24h window carry the match.
|
||||||
|
*/
|
||||||
|
private fun OnrampTransaction.matchesOnrampPayout(onChainTx: TxInfo, currencyAssetId: ExpressAsset.ID): Boolean {
|
||||||
|
if (onChainTx.isOutgoing) return false
|
||||||
|
if (!toAsset.id.matchesAssetId(currencyAssetId)) return false
|
||||||
|
if (onChainTx.destinationAddresses().none { it.matchesAddress(payoutAddress) }) return false
|
||||||
|
if (!isWithinWindow(from = createdAtMillis, upperBase = createdAtMillis, ts = onChainTx.timestampInMillis)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matchesIncomingAmount(onChainTx.amount, actual = toActualAmount, expected = toAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount match for an incoming leg: the [actual] settled amount matched exactly when known, otherwise the
|
||||||
|
* [expected] amount within [INCOMING_SLIPPAGE_TOLERANCE]; no match when neither is known.
|
||||||
|
*/
|
||||||
|
private fun matchesIncomingAmount(txAmount: BigDecimal, actual: BigDecimal?, expected: BigDecimal?): Boolean = when {
|
||||||
|
actual != null -> txAmount.matchesWithin(actual, EXACT_AMOUNT_TOLERANCE)
|
||||||
|
expected != null -> txAmount.matchesWithin(expected, INCOMING_SLIPPAGE_TOLERANCE)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this asset id is the open currency's asset (network id + contract), tolerant of EVM contract casing. */
|
||||||
|
private fun ExpressAsset.ID.matchesAssetId(other: ExpressAsset.ID): Boolean =
|
||||||
|
networkId.equals(other.networkId, ignoreCase = true) &&
|
||||||
|
contractAddress.equals(other.contractAddress, ignoreCase = true)
|
||||||
|
|
||||||
|
/** Relative-difference amount comparison: `|this - target| / |target| <= tolerance`. */
|
||||||
|
private fun BigDecimal.matchesWithin(target: BigDecimal, tolerance: BigDecimal): Boolean {
|
||||||
|
if (target.signum() == 0) return signum() == 0
|
||||||
|
val relativeDiff = (this - target).abs().divide(target.abs(), AMOUNT_SCALE, RoundingMode.HALF_UP)
|
||||||
|
return relativeDiff <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive time window `[from, upperBase + 24h]`. */
|
||||||
|
private fun isWithinWindow(from: Long, upperBase: Long, ts: Long): Boolean {
|
||||||
|
val upperBound = upperBase + TIME_WINDOW_MILLIS
|
||||||
|
return ts in from..upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the on-chain tx was sent from the user's own `from` address — i.e. a self-transfer, not a
|
||||||
|
* provider payout/refund.
|
||||||
|
*/
|
||||||
|
private fun ExchangeTransaction.isSentFromUser(onChainTx: TxInfo): Boolean {
|
||||||
|
val from = fromAddress
|
||||||
|
return onChainTx.sourceAddresses().any { it.matchesAddress(from) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Address equality tolerant of case: EVM addresses differ only by checksum casing, and other chains'
|
||||||
|
* distinct addresses won't collide case-insensitively in practice.
|
||||||
|
*/
|
||||||
|
private fun String.matchesAddress(other: String): Boolean = equals(other, ignoreCase = true)
|
||||||
|
|
||||||
|
private fun TxInfo.sourceAddresses(): List<String> = when (val source = sourceType) {
|
||||||
|
is TxInfo.SourceType.Single -> listOf(source.address)
|
||||||
|
is TxInfo.SourceType.Multiple -> source.addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TxInfo.destinationAddresses(): List<String> = when (val destination = destinationType) {
|
||||||
|
is TxInfo.DestinationType.Single -> listOf(destination.addressType.address)
|
||||||
|
is TxInfo.DestinationType.Multiple -> destination.addressTypes.map { it.address }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale used when computing relative amount differences. */
|
||||||
|
private const val AMOUNT_SCALE = 10
|
||||||
|
|
||||||
|
/** 24h credit/refund buffer for the incoming and refund time windows. */
|
||||||
|
private const val TIME_WINDOW_MILLIS = 86_400_000L
|
||||||
|
|
||||||
|
/** 0% — exact match, used for the pay-in amount off UTXO chains and for the confirmed actual settled amount. */
|
||||||
|
private val EXACT_AMOUNT_TOLERANCE = BigDecimal.ZERO
|
||||||
|
|
||||||
|
/** 0.1% — absorbs UTXO fee/dust variance on the pay-in amount (applied only on UTXO chains). */
|
||||||
|
private val OUTGOING_AMOUNT_TOLERANCE = BigDecimal("0.001")
|
||||||
|
|
||||||
|
/** 5% — payout slippage vs the expected `to` amount (used only when no actual amount is known). */
|
||||||
|
private val INCOMING_SLIPPAGE_TOLERANCE = BigDecimal("0.05")
|
||||||
|
|
||||||
|
/** 15% — fees + volatility on the refunded amount vs the original `from` amount. */
|
||||||
|
private val REFUND_AMOUNT_TOLERANCE = BigDecimal("0.15")
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
package com.tangem.data.txhistory.list.chain
|
package com.tangem.data.txhistory.list.chain
|
||||||
|
|
||||||
|
import com.tangem.data.txhistory.list.chain.BsdkOnChainHistory.Companion.AUTO_LOAD_MORE_TARGET_COUNT
|
||||||
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
|
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
|
||||||
import com.tangem.domain.models.network.TxInfo
|
import com.tangem.domain.models.network.TxInfo
|
||||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||||
|
|
@ -80,8 +81,7 @@ internal class BsdkOnChainHistory @AssistedInject constructor(
|
||||||
.flatMap { it.data.items.asSequence() }
|
.flatMap { it.data.items.asSequence() }
|
||||||
.distinctBy(TxInfo::identityKey)
|
.distinctBy(TxInfo::identityKey)
|
||||||
.toList()
|
.toList()
|
||||||
|
mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
|
||||||
mergeTxHistoryInfos(onChain = onChain, express = express)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (batchState.status) {
|
return when (batchState.status) {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,11 @@ internal class IndexTableOnChainHistory @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(page: ExpressHistoryPage): HistoryState {
|
private fun buildState(page: ExpressHistoryPage): HistoryState {
|
||||||
val merged = mergeTxHistoryInfos(onChain = emptyList(), express = page.items)
|
val merged = mergeTxHistoryInfos(
|
||||||
|
onChain = emptyList(),
|
||||||
|
express = page.items,
|
||||||
|
currency = env.currency,
|
||||||
|
)
|
||||||
return if (merged.isEmpty()) {
|
return if (merged.isEmpty()) {
|
||||||
HistoryState.Empty
|
HistoryState.Empty
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
|
||||||
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
|
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
|
||||||
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
|
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
|
||||||
import com.tangem.data.txhistory.repository.factory.toAssetId
|
import com.tangem.data.txhistory.repository.factory.toAssetId
|
||||||
|
import com.tangem.data.txhistory.repository.factory.toRefundAssetId
|
||||||
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
|
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
|
||||||
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
|
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
|
||||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||||
|
|
@ -170,6 +171,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
fromCurrency = currencies[entity.from.toAssetId()],
|
fromCurrency = currencies[entity.from.toAssetId()],
|
||||||
toCurrency = currencies[entity.to.toAssetId()],
|
toCurrency = currencies[entity.to.toAssetId()],
|
||||||
|
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
|
||||||
)
|
)
|
||||||
add(swapConverter.convert(input))
|
add(swapConverter.convert(input))
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +182,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
||||||
isOutgoing = false,
|
isOutgoing = false,
|
||||||
fromCurrency = currencies[entity.from.toAssetId()],
|
fromCurrency = currencies[entity.from.toAssetId()],
|
||||||
toCurrency = currencies[entity.to.toAssetId()],
|
toCurrency = currencies[entity.to.toAssetId()],
|
||||||
|
refundCurrency = entity.toRefundAssetId()?.let { currencies[it] },
|
||||||
)
|
)
|
||||||
add(swapConverter.convert(input))
|
add(swapConverter.convert(input))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
package com.tangem.data.txhistory.repository.converter
|
package com.tangem.data.txhistory.repository.converter
|
||||||
|
|
||||||
|
import com.tangem.data.txhistory.repository.factory.toRefundAssetId
|
||||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||||
import com.tangem.domain.express.models.ExchangeTransaction
|
import com.tangem.domain.express.models.*
|
||||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
|
||||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
|
||||||
import com.tangem.domain.express.models.ExpressOnrampStatus
|
|
||||||
import com.tangem.domain.express.models.ExpressProvider
|
|
||||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
|
||||||
import com.tangem.domain.express.models.OnrampTransaction
|
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.onramp.model.OnrampCountry
|
import com.tangem.domain.onramp.model.OnrampCountry
|
||||||
import com.tangem.domain.tokens.model.Amount
|
import com.tangem.domain.tokens.model.Amount
|
||||||
|
|
@ -17,6 +12,7 @@ import com.tangem.domain.txhistory.model.ExpressTx
|
||||||
import com.tangem.utils.converter.Converter
|
import com.tangem.utils.converter.Converter
|
||||||
import org.joda.time.DateTime
|
import org.joda.time.DateTime
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps an exchange entity into a swap [ExpressTx.Swap].
|
* Maps an exchange entity into a swap [ExpressTx.Swap].
|
||||||
|
|
@ -37,8 +33,9 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
|
||||||
val entity: ExpressExchangeEntity,
|
val entity: ExpressExchangeEntity,
|
||||||
val provider: ExpressProvider?,
|
val provider: ExpressProvider?,
|
||||||
val isOutgoing: Boolean,
|
val isOutgoing: Boolean,
|
||||||
val fromCurrency: CryptoCurrency? = null,
|
val fromCurrency: CryptoCurrency?,
|
||||||
val toCurrency: CryptoCurrency? = null,
|
val toCurrency: CryptoCurrency?,
|
||||||
|
val refundCurrency: CryptoCurrency?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +43,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
||||||
|
|
||||||
override fun convert(value: Input): ExpressTx.Onramp {
|
override fun convert(value: Input): ExpressTx.Onramp {
|
||||||
val entity = value.entity
|
val entity = value.entity
|
||||||
|
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals)
|
||||||
return ExpressTx.Onramp(
|
return ExpressTx.Onramp(
|
||||||
tx = OnrampTransaction(
|
tx = OnrampTransaction(
|
||||||
txId = entity.txId,
|
txId = entity.txId,
|
||||||
|
|
@ -58,16 +56,18 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
||||||
currencySymbol = entity.fromCurrencyCode,
|
currencySymbol = entity.fromCurrencyCode,
|
||||||
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
||||||
decimals = entity.fromPrecision,
|
decimals = entity.fromPrecision,
|
||||||
type = AmountType.FiatType(code = entity.fromCurrencyCode),
|
type = AmountType.FiatType(code = value.country?.defaultCurrency?.unit ?: entity.fromCurrencyCode),
|
||||||
),
|
),
|
||||||
toAsset = ExpressTransactionAsset(
|
toAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||||
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
|
amount = toActualAmount,
|
||||||
decimals = entity.to.decimals,
|
decimals = entity.to.decimals,
|
||||||
cryptoCurrency = value.toCurrency,
|
cryptoCurrency = value.toCurrency,
|
||||||
),
|
),
|
||||||
country = value.country,
|
country = value.country,
|
||||||
externalTxUrl = entity.externalTxUrl,
|
externalTxUrl = entity.externalTxUrl,
|
||||||
|
toAmount = entity.to.amount?.toScaledBigDecimal(entity.to.decimals),
|
||||||
|
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
|
||||||
),
|
),
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
)
|
)
|
||||||
|
|
@ -83,6 +83,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
||||||
|
|
||||||
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
|
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
|
||||||
val entity = value.entity
|
val entity = value.entity
|
||||||
|
val toActualAmount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals)
|
||||||
return ExchangeTransaction(
|
return ExchangeTransaction(
|
||||||
txId = entity.txId,
|
txId = entity.txId,
|
||||||
status = ExpressExchangeStatus.fromRaw(entity.status),
|
status = ExpressExchangeStatus.fromRaw(entity.status),
|
||||||
|
|
@ -100,11 +101,19 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
|
||||||
),
|
),
|
||||||
toAsset = ExpressTransactionAsset(
|
toAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||||
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
|
amount = toActualAmount,
|
||||||
decimals = entity.to.decimals,
|
decimals = entity.to.decimals,
|
||||||
cryptoCurrency = value.toCurrency,
|
cryptoCurrency = value.toCurrency,
|
||||||
),
|
),
|
||||||
externalTxUrl = entity.externalTxUrl,
|
externalTxUrl = entity.externalTxUrl,
|
||||||
|
payinAddress = entity.payinAddress,
|
||||||
|
updatedAtMillis = parseIsoMillis(entity.updatedAt),
|
||||||
|
refundAssetId = entity.toRefundAssetId(),
|
||||||
|
refundCurrency = value.refundCurrency,
|
||||||
|
|
||||||
|
fromAmount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
|
||||||
|
toAmount = entity.to.amount.toScaledBigDecimal(entity.to.decimals),
|
||||||
|
toActualAmount = entity.to.actualAmount?.toScaledBigDecimal(entity.to.decimals),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
|
||||||
(outgoingSwaps + incomingSwaps).forEach { entity ->
|
(outgoingSwaps + incomingSwaps).forEach { entity ->
|
||||||
add(entity.from.toAssetId())
|
add(entity.from.toAssetId())
|
||||||
add(entity.to.toAssetId())
|
add(entity.to.toAssetId())
|
||||||
|
entity.toRefundAssetId()?.let { add(it) }
|
||||||
}
|
}
|
||||||
onramps.forEach { entity -> add(entity.to.toAssetId()) }
|
onramps.forEach { entity -> add(entity.to.toAssetId()) }
|
||||||
}
|
}
|
||||||
|
|
@ -125,5 +126,15 @@ internal class ExpressTransactionAssetFactory @Inject constructor(
|
||||||
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||||
|
|
||||||
|
internal fun ExpressExchangeEntity.toRefundAssetId(): ExpressAsset.ID? {
|
||||||
|
val refundNetwork = refundNetwork
|
||||||
|
val refundContractAddress = refundContractAddress
|
||||||
|
return if (refundNetwork != null && refundContractAddress != null) {
|
||||||
|
ExpressAsset.ID(refundNetwork, refundContractAddress)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
package com.tangem.data.txhistory.list
|
package com.tangem.data.txhistory.list
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||||
import com.tangem.domain.express.models.ExchangeTransaction
|
import com.tangem.domain.express.models.ExchangeTransaction
|
||||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||||
|
import com.tangem.domain.express.models.ExpressOnrampStatus
|
||||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||||
|
import com.tangem.domain.express.models.OnrampTransaction
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.TxInfo
|
import com.tangem.domain.models.network.TxInfo
|
||||||
|
import com.tangem.domain.tokens.model.Amount
|
||||||
|
import com.tangem.domain.tokens.model.AmountType
|
||||||
import com.tangem.domain.txhistory.model.ExpressTx
|
import com.tangem.domain.txhistory.model.ExpressTx
|
||||||
import com.tangem.domain.txhistory.model.OnChainTx
|
import com.tangem.domain.txhistory.model.OnChainTx
|
||||||
|
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||||
|
import com.tangem.domain.txhistory.model.explorerHash
|
||||||
|
import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
@ -15,95 +24,642 @@ import java.math.BigDecimal
|
||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
internal class TxHistoryInfoMergerTest {
|
internal class TxHistoryInfoMergerTest {
|
||||||
|
|
||||||
@Test
|
@Nested
|
||||||
fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() {
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
// Arrange
|
inner class DeterministicHashMatch {
|
||||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
|
||||||
val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting))
|
|
||||||
|
|
||||||
// Act
|
@Test
|
||||||
val result = mergeTxHistoryInfos(onChain, express)
|
fun `GIVEN express op matched to on-chain by hash WHEN merge THEN enriched single row and on-chain not duplicated`() {
|
||||||
|
// Arrange
|
||||||
|
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||||
|
val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting))
|
||||||
|
|
||||||
// Assert
|
// Act
|
||||||
assertThat(result).hasSize(1)
|
val result = merge(onChain, express)
|
||||||
val row = result.single()
|
|
||||||
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
// Assert
|
||||||
assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
|
assertThat(result).hasSize(1)
|
||||||
|
val row = result.single()
|
||||||
|
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||||
|
assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() {
|
||||||
|
// Arrange
|
||||||
|
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched terminal outgoing swap WHEN merge THEN standalone row kept`() {
|
||||||
|
// Arrange: the outgoing (pay-in) side is always kept — see UnmatchedTerminalHiding for the hidden case.
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished, isOutgoing = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
val row = result.single()
|
||||||
|
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||||
|
assertThat((row as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() {
|
||||||
|
// Arrange
|
||||||
|
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express = emptyList())
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() {
|
||||||
|
// Arrange
|
||||||
|
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Nested
|
||||||
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() {
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
// Arrange
|
inner class HeuristicFallbackMatch {
|
||||||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
|
|
||||||
|
|
||||||
// Act
|
@Test
|
||||||
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
fun `GIVEN incoming swap without payout hash WHEN on-chain credit matches address amount time THEN one enriched row`() {
|
||||||
|
// Arrange: the provider never returned the payout hash, so only the heuristic can join the pair.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Finished,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Act
|
||||||
assertThat(result).hasSize(1)
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
val row = result.single()
|
||||||
|
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||||
|
assertThat((row as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN incoming swap WHEN on-chain sender is the user THEN self-transfer not matched and both rows kept`() {
|
||||||
|
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "mySource",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Waiting,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN incoming swap WHEN on-chain tx is outside the 24h window THEN not matched`() {
|
||||||
|
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 100 + DAY_MILLIS + 1,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Waiting,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN incoming swap with expected amount only WHEN on-chain amount is beyond slippage THEN not matched`() {
|
||||||
|
// Arrange: active deal so the unmatched express row stays visible (terminal hiding is covered elsewhere).
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.01"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Waiting,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN outgoing swap without payin hash WHEN on-chain pay-in matches deposit address and amount THEN matched`() {
|
||||||
|
// Arrange
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "payin",
|
||||||
|
timestamp = 120,
|
||||||
|
isOutgoing = true,
|
||||||
|
sourceAddress = "mySource",
|
||||||
|
destinationAddress = "providerDeposit",
|
||||||
|
amount = BigDecimal("1.5"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Confirming,
|
||||||
|
isOutgoing = true,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
payinAddress = "providerDeposit",
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("payin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN refunded swap WHEN incoming refund is within window and amount tolerance THEN matched`() {
|
||||||
|
// Arrange: refund returns the from-asset to the user on the from-token screen.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "refund",
|
||||||
|
timestamp = 2000,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "mySource",
|
||||||
|
amount = BigDecimal("1.4"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Refunded,
|
||||||
|
isOutgoing = true,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
updatedAtMillis = 1000,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("refund")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN onramp without payout hash WHEN on-chain credit matches address amount time THEN matched`() {
|
||||||
|
// Arrange
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createOnramp(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressOnrampStatus.Finished,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two on-chain candidates WHEN incoming swap matches both THEN the closest in time is claimed and the other passes through`() {
|
||||||
|
// Arrange
|
||||||
|
val near = createTxInfo(
|
||||||
|
txHash = "near",
|
||||||
|
timestamp = 150,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
)
|
||||||
|
val far = createTxInfo(
|
||||||
|
txHash = "far",
|
||||||
|
timestamp = 500,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Finished,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = listOf(near, far), express = express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
val enriched = result.filterIsInstance<ExpressTx>().single()
|
||||||
|
assertThat(enriched.txInfo?.explorerHash).isEqualTo("near")
|
||||||
|
val passthrough = result.filterIsInstance<OnChainTx.BSDK>().single()
|
||||||
|
assertThat(passthrough.txInfo.txHash).isEqualTo("far")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Nested
|
||||||
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() {
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
// Arrange
|
inner class CurrencyAndAmountScoping {
|
||||||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
|
|
||||||
|
|
||||||
// Act
|
@Test
|
||||||
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
fun `GIVEN incoming swap WHEN open currency is not the to-asset THEN not matched`() {
|
||||||
|
// Arrange: address/amount/time all line up, but the open currency is the swap's from-asset, not the to-asset.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Waiting,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Act
|
||||||
assertThat(result).hasSize(1)
|
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||||
val row = result.single()
|
|
||||||
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
// Assert
|
||||||
assertThat((row as ExpressTx).txInfo).isNull()
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN incoming swap with actual amount WHEN on-chain equals actual THEN matched`() {
|
||||||
|
// Arrange: the settled amount differs from the expected one; the actual value carries the match.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.00095"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Finished,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
toActualAmount = BigDecimal("0.00095"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("credit")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN incoming swap with actual amount WHEN on-chain differs from actual THEN not matched despite expected slippage`() {
|
||||||
|
// Arrange: on-chain 0.001 is within 5% of the expected 0.001, but the actual is 0.0009 and matched exactly.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "credit",
|
||||||
|
timestamp = 200,
|
||||||
|
isOutgoing = false,
|
||||||
|
sourceAddress = "provider",
|
||||||
|
destinationAddress = "myPayout",
|
||||||
|
amount = BigDecimal("0.001"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Waiting,
|
||||||
|
isOutgoing = false,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
payoutAddress = "myPayout",
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
toActualAmount = BigDecimal("0.0009"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN outgoing pay-in on UTXO chain WHEN amount is within dust tolerance THEN matched`() {
|
||||||
|
// Arrange: 1.5008 vs 1.5 is ~0.05% off — within the 0.1% UTXO fee/dust tolerance.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "payin",
|
||||||
|
timestamp = 120,
|
||||||
|
isOutgoing = true,
|
||||||
|
sourceAddress = "mySource",
|
||||||
|
destinationAddress = "providerDeposit",
|
||||||
|
amount = BigDecimal("1.5008"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Confirming,
|
||||||
|
isOutgoing = true,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
payinAddress = "providerDeposit",
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
fromCurrency = UTXO_CURRENCY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = UTXO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo?.explorerHash).isEqualTo("payin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN outgoing pay-in on non-UTXO chain WHEN amount is not exact THEN not matched`() {
|
||||||
|
// Arrange: same ~0.05% offset, but off UTXO the pay-in amount must match exactly.
|
||||||
|
val onChain = listOf(
|
||||||
|
createTxInfo(
|
||||||
|
txHash = "payin",
|
||||||
|
timestamp = 120,
|
||||||
|
isOutgoing = true,
|
||||||
|
sourceAddress = "mySource",
|
||||||
|
destinationAddress = "providerDeposit",
|
||||||
|
amount = BigDecimal("1.5008"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(
|
||||||
|
matchHash = null,
|
||||||
|
status = ExpressExchangeStatus.Confirming,
|
||||||
|
isOutgoing = true,
|
||||||
|
createdAtMillis = 100,
|
||||||
|
fromAddress = "mySource",
|
||||||
|
payinAddress = "providerDeposit",
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain, express, currency = FROM_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Nested
|
||||||
fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() {
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
// Arrange
|
inner class UnmatchedTerminalHiding {
|
||||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
|
||||||
|
|
||||||
// Act
|
@Test
|
||||||
val result = mergeTxHistoryInfos(onChain, express = emptyList())
|
fun `GIVEN unmatched terminal unsuccessful incoming swap WHEN merge THEN hidden`() {
|
||||||
|
// Arrange: to-token screen (isOutgoing = false), deal ended (expired) without ever landing on-chain.
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Expired, isOutgoing = false),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Act
|
||||||
assertThat(result).hasSize(1)
|
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
|
||||||
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched finished incoming swap WHEN merge THEN standalone row kept`() {
|
||||||
|
// Arrange: a successful incoming swap is always shown, even when its on-chain credit could not be matched
|
||||||
|
// (e.g. the index-table backbone has no on-chain row to fall back to).
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished, isOutgoing = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched active incoming swap WHEN merge THEN standalone row kept`() {
|
||||||
|
// Arrange: not terminal yet, so the live row must still be shown.
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched terminal refunded swap on from-token WHEN merge THEN standalone row kept`() {
|
||||||
|
// Arrange: refund lands on the open from-token (isOutgoing = true), so it is never hidden.
|
||||||
|
val express = listOf(
|
||||||
|
createSwap(matchHash = "missing", status = ExpressExchangeStatus.Refunded, isOutgoing = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express, currency = FROM_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN unmatched terminal onramp WHEN merge THEN standalone row kept`() {
|
||||||
|
// Arrange: an onramp purchase is always kept, even terminal without an on-chain leg.
|
||||||
|
val express = listOf(createOnramp(matchHash = "missing", status = ExpressOnrampStatus.Finished))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = merge(onChain = emptyList(), express = express, currency = TO_CURRENCY)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun merge(
|
||||||
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() {
|
onChain: List<TxInfo>,
|
||||||
// Arrange
|
express: List<ExpressTx>,
|
||||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
currency: CryptoCurrency = FROM_CURRENCY,
|
||||||
val express = listOf(
|
): List<TxHistoryInfo> = mergeTxHistoryInfos(onChain = onChain, express = express, currency = currency)
|
||||||
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
private fun createTxInfo(
|
||||||
val result = mergeTxHistoryInfos(onChain, express)
|
txHash: String,
|
||||||
|
timestamp: Long,
|
||||||
// Assert
|
isOutgoing: Boolean = true,
|
||||||
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
|
sourceAddress: String = "addr",
|
||||||
}
|
destinationAddress: String = "addr",
|
||||||
|
amount: BigDecimal = BigDecimal.ONE,
|
||||||
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
|
) = TxInfo(
|
||||||
txHash = txHash,
|
txHash = txHash,
|
||||||
timestampInMillis = timestamp,
|
timestampInMillis = timestamp,
|
||||||
isOutgoing = true,
|
isOutgoing = isOutgoing,
|
||||||
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
|
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(destinationAddress)),
|
||||||
sourceType = TxInfo.SourceType.Single("addr"),
|
sourceType = TxInfo.SourceType.Single(sourceAddress),
|
||||||
interactionAddressType = null,
|
interactionAddressType = null,
|
||||||
status = TxInfo.TransactionStatus.Confirmed,
|
status = TxInfo.TransactionStatus.Confirmed,
|
||||||
type = TxInfo.TransactionType.Transfer,
|
type = TxInfo.TransactionType.Transfer,
|
||||||
amount = BigDecimal.ONE,
|
amount = amount,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private fun createSwap(
|
private fun createSwap(
|
||||||
matchHash: String?,
|
matchHash: String?,
|
||||||
status: ExpressExchangeStatus,
|
status: ExpressExchangeStatus,
|
||||||
createdAtMillis: Long = 100,
|
createdAtMillis: Long = 100,
|
||||||
isOutgoing: Boolean = true,
|
isOutgoing: Boolean = true,
|
||||||
|
updatedAtMillis: Long = createdAtMillis,
|
||||||
|
fromAddress: String = "fromAddr",
|
||||||
|
payoutAddress: String = "payoutAddr",
|
||||||
|
payinAddress: String = "payinAddr",
|
||||||
|
fromAmount: BigDecimal? = BigDecimal("1.5"),
|
||||||
|
toAmount: BigDecimal? = BigDecimal("0.001"),
|
||||||
|
toActualAmount: BigDecimal? = null,
|
||||||
|
fromCurrency: CryptoCurrency = FROM_CURRENCY,
|
||||||
|
toCurrency: CryptoCurrency = TO_CURRENCY,
|
||||||
) = ExpressTx.Swap(
|
) = ExpressTx.Swap(
|
||||||
tx = ExchangeTransaction(
|
tx = ExchangeTransaction(
|
||||||
txId = "tx-1",
|
txId = "tx-1",
|
||||||
|
|
@ -112,20 +668,78 @@ internal class TxHistoryInfoMergerTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = matchHash.takeIf { isOutgoing },
|
payinHash = matchHash.takeIf { isOutgoing },
|
||||||
payoutHash = matchHash.takeUnless { isOutgoing },
|
payoutHash = matchHash.takeUnless { isOutgoing },
|
||||||
fromAddress = null,
|
fromAddress = fromAddress,
|
||||||
payoutAddress = null,
|
payoutAddress = payoutAddress,
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
id = ExpressAssetId(fromCurrency),
|
||||||
amount = BigDecimal("1.5"),
|
amount = fromAmount,
|
||||||
decimals = 18,
|
decimals = 18,
|
||||||
),
|
),
|
||||||
toAsset = ExpressTransactionAsset(
|
toAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
|
id = ExpressAssetId(toCurrency),
|
||||||
amount = BigDecimal("0.001"),
|
amount = toAmount,
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = payinAddress,
|
||||||
|
updatedAtMillis = updatedAtMillis,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = fromAmount ?: BigDecimal.ZERO,
|
||||||
|
toAmount = toAmount ?: BigDecimal.ZERO,
|
||||||
|
toActualAmount = toActualAmount,
|
||||||
),
|
),
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun createOnramp(
|
||||||
|
matchHash: String?,
|
||||||
|
status: ExpressOnrampStatus,
|
||||||
|
createdAtMillis: Long = 100,
|
||||||
|
payoutAddress: String = "payoutAddr",
|
||||||
|
toAmount: BigDecimal? = BigDecimal("0.001"),
|
||||||
|
toActualAmount: BigDecimal? = null,
|
||||||
|
toCurrency: CryptoCurrency = TO_CURRENCY,
|
||||||
|
) = ExpressTx.Onramp(
|
||||||
|
tx = OnrampTransaction(
|
||||||
|
txId = "onramp-1",
|
||||||
|
status = status,
|
||||||
|
createdAtMillis = createdAtMillis,
|
||||||
|
provider = null,
|
||||||
|
payoutHash = matchHash,
|
||||||
|
payoutAddress = payoutAddress,
|
||||||
|
fromFiat = Amount(
|
||||||
|
currencySymbol = "USD",
|
||||||
|
value = BigDecimal.TEN,
|
||||||
|
decimals = 2,
|
||||||
|
type = AmountType.FiatType(code = "USD"),
|
||||||
|
),
|
||||||
|
toAsset = ExpressTransactionAsset(
|
||||||
|
id = ExpressAssetId(toCurrency),
|
||||||
|
amount = toAmount,
|
||||||
|
decimals = 8,
|
||||||
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
country = null,
|
||||||
|
toAmount = toAmount,
|
||||||
|
toActualAmount = toActualAmount,
|
||||||
|
),
|
||||||
|
txInfo = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DAY_MILLIS = 86_400_000L
|
||||||
|
|
||||||
|
private val currencyFactory = MockCryptoCurrencyFactory()
|
||||||
|
|
||||||
|
/** Open currency = the swap's `from` / pay-in asset (non-UTXO chain). */
|
||||||
|
val FROM_CURRENCY: CryptoCurrency = currencyFactory.ethereum
|
||||||
|
|
||||||
|
/** Open currency = the swap's `to` / payout (and onramp) asset. */
|
||||||
|
val TO_CURRENCY: CryptoCurrency = currencyFactory.stellar
|
||||||
|
|
||||||
|
/** UTXO chain, used to exercise the pay-in dust tolerance. */
|
||||||
|
val UTXO_CURRENCY: CryptoCurrency = currencyFactory.bitcoin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,8 +231,8 @@ internal class BsdkOnChainHistoryTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = matchHash,
|
payinHash = matchHash,
|
||||||
payoutHash = null,
|
payoutHash = null,
|
||||||
fromAddress = null,
|
fromAddress = "from-addr",
|
||||||
payoutAddress = null,
|
payoutAddress = "payout-addr",
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
|
|
@ -243,6 +243,14 @@ internal class BsdkOnChainHistoryTest {
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = 100,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = BigDecimal.ONE,
|
||||||
|
toAmount = BigDecimal.ONE,
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@ internal class IndexTableOnChainHistoryTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = null,
|
payinHash = null,
|
||||||
payoutHash = null,
|
payoutHash = null,
|
||||||
fromAddress = null,
|
fromAddress = "from-addr",
|
||||||
payoutAddress = null,
|
payoutAddress = "payout-addr",
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
|
|
@ -131,6 +131,14 @@ internal class IndexTableOnChainHistoryTest {
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = 100,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = BigDecimal.ONE,
|
||||||
|
toAmount = BigDecimal.ONE,
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ internal class TangemPayOnChainHistoryTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = matchHash,
|
payinHash = matchHash,
|
||||||
payoutHash = null,
|
payoutHash = null,
|
||||||
fromAddress = null,
|
fromAddress = "from-addr",
|
||||||
payoutAddress = null,
|
payoutAddress = "payout-addr",
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
|
|
@ -170,6 +170,14 @@ internal class TangemPayOnChainHistoryTest {
|
||||||
amount = BigDecimal.ONE,
|
amount = BigDecimal.ONE,
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = 100,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = BigDecimal.ONE,
|
||||||
|
toAmount = BigDecimal.ONE,
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,7 @@ internal class DefaultExpressHistoryRepositoryTest {
|
||||||
refundNetwork = null,
|
refundNetwork = null,
|
||||||
refundContractAddress = null,
|
refundContractAddress = null,
|
||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
|
updatedAt = "2026-06-01T00:00:00Z",
|
||||||
payTill = null,
|
payTill = null,
|
||||||
averageDuration = null,
|
averageDuration = null,
|
||||||
fromContractAddress = "0xfromContract",
|
fromContractAddress = "0xfromContract",
|
||||||
|
|
@ -356,6 +357,7 @@ internal class DefaultExpressHistoryRepositoryTest {
|
||||||
externalTxUrl = null,
|
externalTxUrl = null,
|
||||||
payoutHash = "payout-hash",
|
payoutHash = "payout-hash",
|
||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
|
updatedAt = "2026-06-01T00:00:00Z",
|
||||||
fromCurrencyCode = "USD",
|
fromCurrencyCode = "USD",
|
||||||
fromAmount = "100.0",
|
fromAmount = "100.0",
|
||||||
fromPrecision = 2,
|
fromPrecision = 2,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,16 @@ internal class ExpressTxHistoryConverterTest {
|
||||||
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting")
|
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting")
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
|
val swap = swapConverter.convert(
|
||||||
|
ExpressSwapConverter.Input(
|
||||||
|
entity = entity,
|
||||||
|
provider = null,
|
||||||
|
isOutgoing = true,
|
||||||
|
fromCurrency = null,
|
||||||
|
toCurrency = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(swap.isOutgoing).isTrue()
|
assertThat(swap.isOutgoing).isTrue()
|
||||||
|
|
@ -42,7 +51,16 @@ internal class ExpressTxHistoryConverterTest {
|
||||||
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout")
|
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout")
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = false))
|
val swap = swapConverter.convert(
|
||||||
|
ExpressSwapConverter.Input(
|
||||||
|
entity = entity,
|
||||||
|
provider = null,
|
||||||
|
isOutgoing = false,
|
||||||
|
fromCurrency = null,
|
||||||
|
toCurrency = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(swap.isOutgoing).isFalse()
|
assertThat(swap.isOutgoing).isFalse()
|
||||||
|
|
@ -55,7 +73,16 @@ internal class ExpressTxHistoryConverterTest {
|
||||||
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
|
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
|
val swap = swapConverter.convert(
|
||||||
|
ExpressSwapConverter.Input(
|
||||||
|
entity = entity,
|
||||||
|
provider = null,
|
||||||
|
isOutgoing = true,
|
||||||
|
fromCurrency = null,
|
||||||
|
toCurrency = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
|
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.tangem.domain.express.models
|
package com.tangem.domain.express.models
|
||||||
|
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An express exchange (swap) operation, independent of how it is presented in the transaction history.
|
* An express exchange (swap) operation, independent of how it is presented in the transaction history.
|
||||||
*
|
*
|
||||||
|
|
@ -11,14 +14,17 @@ package com.tangem.domain.express.models
|
||||||
* @property provider The provider behind the deal; `null` if not resolved.
|
* @property provider The provider behind the deal; `null` if not resolved.
|
||||||
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
|
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
|
||||||
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
|
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
|
||||||
* @property fromAddress Address the `from` assets were taken from (the user's own source address); `null` when unknown
|
* @property fromAddress Address the `from` assets were taken from (the user's own source address).
|
||||||
* (very old app versions did not send it).
|
* @property payoutAddress Address that received the `to` assets — the user's own address for a regular swap, an
|
||||||
* @property payoutAddress Address that received the `to` assets — the user's own address for a regular swap, an external
|
* external one for a send-and-swap.
|
||||||
* one for a send-and-swap; `null` when unknown.
|
|
||||||
* @property fromAsset The asset sent.
|
* @property fromAsset The asset sent.
|
||||||
* @property toAsset The asset received.
|
* @property toAsset The asset received.
|
||||||
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
|
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
|
||||||
* supplies none (CEX only).
|
* supplies none (CEX only).
|
||||||
|
* @property payinAddress Provider deposit address the pay-in was sent to — the per-deal discriminator for the
|
||||||
|
* heuristic on-chain match of the outgoing (pay-in) leg.
|
||||||
|
* @property updatedAtMillis Last status-update timestamp (ms since epoch). Bounds the refund heuristic time
|
||||||
|
|
||||||
*/
|
*/
|
||||||
data class ExchangeTransaction(
|
data class ExchangeTransaction(
|
||||||
val txId: String,
|
val txId: String,
|
||||||
|
|
@ -27,9 +33,17 @@ data class ExchangeTransaction(
|
||||||
val provider: ExpressProvider?,
|
val provider: ExpressProvider?,
|
||||||
val payinHash: String?,
|
val payinHash: String?,
|
||||||
val payoutHash: String?,
|
val payoutHash: String?,
|
||||||
val fromAddress: String?,
|
val fromAddress: String,
|
||||||
val payoutAddress: String?,
|
val payoutAddress: String,
|
||||||
val fromAsset: ExpressTransactionAsset,
|
val fromAsset: ExpressTransactionAsset,
|
||||||
val toAsset: ExpressTransactionAsset,
|
val toAsset: ExpressTransactionAsset,
|
||||||
val externalTxUrl: String? = null,
|
val externalTxUrl: String?,
|
||||||
|
val payinAddress: String,
|
||||||
|
val updatedAtMillis: Long,
|
||||||
|
val refundAssetId: ExpressAsset.ID?,
|
||||||
|
val refundCurrency: CryptoCurrency?,
|
||||||
|
|
||||||
|
val fromAmount: BigDecimal,
|
||||||
|
val toAmount: BigDecimal,
|
||||||
|
val toActualAmount: BigDecimal?,
|
||||||
)
|
)
|
||||||
|
|
@ -53,6 +53,28 @@ enum class ExpressExchangeStatus(val raw: String) {
|
||||||
-> false
|
-> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isFinished: Boolean
|
||||||
|
get() = when (this) {
|
||||||
|
Finished,
|
||||||
|
-> true
|
||||||
|
Preview,
|
||||||
|
Expired,
|
||||||
|
Unknown,
|
||||||
|
Refunded,
|
||||||
|
TxFailed,
|
||||||
|
Paused,
|
||||||
|
Created,
|
||||||
|
ExchangeTxSent,
|
||||||
|
Waiting,
|
||||||
|
WaitingTxHash,
|
||||||
|
Confirming,
|
||||||
|
Exchanging,
|
||||||
|
Sending,
|
||||||
|
Failed,
|
||||||
|
Verifying,
|
||||||
|
-> false
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
|
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.domain.express.models
|
||||||
import com.tangem.domain.onramp.model.OnrampCountry
|
import com.tangem.domain.onramp.model.OnrampCountry
|
||||||
import com.tangem.domain.tokens.model.Amount
|
import com.tangem.domain.tokens.model.Amount
|
||||||
import com.tangem.domain.tokens.model.AmountType
|
import com.tangem.domain.tokens.model.AmountType
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An express onramp operation, independent of how it is presented in the transaction history.
|
* An express onramp operation, independent of how it is presented in the transaction history.
|
||||||
|
|
@ -14,12 +15,12 @@ import com.tangem.domain.tokens.model.AmountType
|
||||||
|
|
||||||
* @property provider The provider behind the deal; `null` if not resolved.
|
* @property provider The provider behind the deal; `null` if not resolved.
|
||||||
* @property payoutHash On-chain hash of the payout (received) leg, if known.
|
* @property payoutHash On-chain hash of the payout (received) leg, if known.
|
||||||
* @property payoutAddress Address that received the crypto (the user's own address); `null` when unknown.
|
* @property payoutAddress Address that received the crypto (the user's own address).
|
||||||
* @property fromFiat The fiat paid.
|
* @property fromFiat The fiat paid.
|
||||||
* @property toAsset The crypto asset received.
|
* @property toAsset The crypto asset received.
|
||||||
* @property country The country the onramp was made from; `null` if not resolved.
|
|
||||||
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
|
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
|
||||||
* supplies none (not provided by all providers).
|
* supplies none (not provided by all providers).
|
||||||
|
* @property country The country the onramp was made from; `null` if not resolved.
|
||||||
*/
|
*/
|
||||||
data class OnrampTransaction(
|
data class OnrampTransaction(
|
||||||
val txId: String,
|
val txId: String,
|
||||||
|
|
@ -27,10 +28,12 @@ data class OnrampTransaction(
|
||||||
val createdAtMillis: Long,
|
val createdAtMillis: Long,
|
||||||
val provider: ExpressProvider?,
|
val provider: ExpressProvider?,
|
||||||
val payoutHash: String?,
|
val payoutHash: String?,
|
||||||
val payoutAddress: String?,
|
val payoutAddress: String,
|
||||||
/** The [Amount.type] is [AmountType.FiatType] . */
|
/** The [Amount.type] is [AmountType.FiatType] . */
|
||||||
val fromFiat: Amount,
|
val fromFiat: Amount,
|
||||||
val toAsset: ExpressTransactionAsset,
|
val toAsset: ExpressTransactionAsset,
|
||||||
val country: OnrampCountry? = null,
|
val country: OnrampCountry?,
|
||||||
val externalTxUrl: String? = null,
|
val externalTxUrl: String?,
|
||||||
|
val toAmount: BigDecimal?,
|
||||||
|
val toActualAmount: BigDecimal?,
|
||||||
)
|
)
|
||||||
|
|
@ -12,9 +12,12 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
|
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
|
||||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
import com.tangem.domain.models.network.TxInfo
|
||||||
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
|
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
|
||||||
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
|
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
|
||||||
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
|
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
|
||||||
|
import com.tangem.domain.txhistory.model.ExpressTx
|
||||||
|
import com.tangem.domain.txhistory.model.OnChainTx
|
||||||
import com.tangem.domain.txhistory.list.HistoryTxListManager
|
import com.tangem.domain.txhistory.list.HistoryTxListManager
|
||||||
import com.tangem.domain.txhistory.list.txHistoryInfoFlow
|
import com.tangem.domain.txhistory.list.txHistoryInfoFlow
|
||||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||||
|
|
@ -346,10 +349,18 @@ internal class TxHistoryModel @Inject constructor(
|
||||||
override fun onTransactionClick(item: TxHistoryInfo) {
|
override fun onTransactionClick(item: TxHistoryInfo) {
|
||||||
// manager is non-null only under the new tx-history toggle.
|
// manager is non-null only under the new tx-history toggle.
|
||||||
val manager = historyTxListManager
|
val manager = historyTxListManager
|
||||||
if (manager != null) {
|
if (manager != null && item.opensInAppDetails()) {
|
||||||
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
|
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
|
||||||
} else {
|
} else {
|
||||||
item.explorerHash?.let(::openTxInExplorer)
|
item.explorerHash?.let(::openTxInExplorer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */
|
||||||
|
private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) {
|
||||||
|
is ExpressTx -> true
|
||||||
|
is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap
|
||||||
|
// Standalone TangemPay rows are not yet rendered in-app; route them to the explorer for now.
|
||||||
|
is OnChainTx.TangemPay -> false
|
||||||
}
|
}
|
||||||
|
|
@ -483,18 +483,6 @@ internal class ExpressTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
|
||||||
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
|
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN swap without addresses WHEN convert THEN no owner and default labels`() {
|
|
||||||
// Act — no fromAddress / payoutAddress plumbed (e.g. very old app version).
|
|
||||||
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.from?.owner).isNull()
|
|
||||||
assertThat(result.to?.owner).isNull()
|
|
||||||
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.swapping_from_title_v2))
|
|
||||||
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.swapping_to_title))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() {
|
fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
|
||||||
|
|
@ -226,8 +226,8 @@ internal class ExpressTxToTransactionItemUMConverterTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = null,
|
payinHash = null,
|
||||||
payoutHash = null,
|
payoutHash = null,
|
||||||
fromAddress = null,
|
fromAddress = "from-addr",
|
||||||
payoutAddress = null,
|
payoutAddress = "payout-addr",
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||||
amount = fromAmount,
|
amount = fromAmount,
|
||||||
|
|
@ -238,34 +238,48 @@ internal class ExpressTxToTransactionItemUMConverterTest {
|
||||||
amount = toAmount,
|
amount = toAmount,
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = 100,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = fromAmount ?: BigDecimal.ZERO,
|
||||||
|
toAmount = toAmount ?: BigDecimal.ZERO,
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createOnramp(status: ExpressOnrampStatus, toAmount: BigDecimal? = BigDecimal("0.006339")) =
|
private fun createOnramp(
|
||||||
ExpressTx.Onramp(
|
status: ExpressOnrampStatus,
|
||||||
tx = OnrampTransaction(
|
toAmount: BigDecimal? = BigDecimal("0.006339"),
|
||||||
txId = "tx-2",
|
) = ExpressTx.Onramp(
|
||||||
status = status,
|
tx = OnrampTransaction(
|
||||||
createdAtMillis = 100,
|
txId = "tx-2",
|
||||||
provider = null,
|
status = status,
|
||||||
payoutHash = null,
|
createdAtMillis = 100,
|
||||||
payoutAddress = null,
|
provider = null,
|
||||||
fromFiat = Amount(
|
payoutHash = null,
|
||||||
currencySymbol = "SEK",
|
payoutAddress = "payout-addr",
|
||||||
value = BigDecimal("100"),
|
fromFiat = Amount(
|
||||||
decimals = 2,
|
currencySymbol = "SEK",
|
||||||
type = AmountType.FiatType(code = "SEK"),
|
value = BigDecimal("100"),
|
||||||
),
|
decimals = 2,
|
||||||
toAsset = ExpressTransactionAsset(
|
type = AmountType.FiatType(code = "SEK"),
|
||||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
|
|
||||||
amount = toAmount,
|
|
||||||
decimals = 8,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
txInfo = null,
|
toAsset = ExpressTransactionAsset(
|
||||||
)
|
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
|
||||||
|
amount = toAmount,
|
||||||
|
decimals = 8,
|
||||||
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
country = null,
|
||||||
|
toAmount = toAmount,
|
||||||
|
toActualAmount = null,
|
||||||
|
),
|
||||||
|
txInfo = null,
|
||||||
|
)
|
||||||
|
|
||||||
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||||
id = CryptoCurrency.ID(
|
id = CryptoCurrency.ID(
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,8 @@ internal open class TxDetailsConverterTestBase {
|
||||||
txInfo: OnChainTx? = null,
|
txInfo: OnChainTx? = null,
|
||||||
provider: ExpressProvider? = null,
|
provider: ExpressProvider? = null,
|
||||||
externalTxUrl: String? = null,
|
externalTxUrl: String? = null,
|
||||||
fromAddress: String? = null,
|
fromAddress: String = FROM_ADDRESS,
|
||||||
payoutAddress: String? = null,
|
payoutAddress: String = PAYOUT_ADDRESS,
|
||||||
fromCurrency: CryptoCurrency? = null,
|
fromCurrency: CryptoCurrency? = null,
|
||||||
): ExpressTx.Swap = ExpressTx.Swap(
|
): ExpressTx.Swap = ExpressTx.Swap(
|
||||||
tx = ExchangeTransaction(
|
tx = ExchangeTransaction(
|
||||||
|
|
@ -192,6 +192,13 @@ internal open class TxDetailsConverterTestBase {
|
||||||
cryptoCurrency = bitcoin,
|
cryptoCurrency = bitcoin,
|
||||||
),
|
),
|
||||||
externalTxUrl = externalTxUrl,
|
externalTxUrl = externalTxUrl,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = TIMESTAMP,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
txInfo = txInfo,
|
txInfo = txInfo,
|
||||||
|
|
@ -201,7 +208,7 @@ internal open class TxDetailsConverterTestBase {
|
||||||
status: ExpressOnrampStatus,
|
status: ExpressOnrampStatus,
|
||||||
txInfo: OnChainTx? = null,
|
txInfo: OnChainTx? = null,
|
||||||
externalTxUrl: String? = null,
|
externalTxUrl: String? = null,
|
||||||
payoutAddress: String? = null,
|
payoutAddress: String = PAYOUT_ADDRESS,
|
||||||
): ExpressTx.Onramp = ExpressTx.Onramp(
|
): ExpressTx.Onramp = ExpressTx.Onramp(
|
||||||
tx = OnrampTransaction(
|
tx = OnrampTransaction(
|
||||||
txId = "onramp-1",
|
txId = "onramp-1",
|
||||||
|
|
@ -223,6 +230,9 @@ internal open class TxDetailsConverterTestBase {
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
cryptoCurrency = bitcoin,
|
cryptoCurrency = bitcoin,
|
||||||
),
|
),
|
||||||
|
country = null,
|
||||||
|
toAmount = BigDecimal("0.006"),
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
txInfo = txInfo,
|
txInfo = txInfo,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,8 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
|
||||||
provider = null,
|
provider = null,
|
||||||
payinHash = null,
|
payinHash = null,
|
||||||
payoutHash = null,
|
payoutHash = null,
|
||||||
fromAddress = null,
|
fromAddress = "from-addr",
|
||||||
payoutAddress = null,
|
payoutAddress = "payout-addr",
|
||||||
fromAsset = ExpressTransactionAsset(
|
fromAsset = ExpressTransactionAsset(
|
||||||
id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"),
|
id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"),
|
||||||
amount = BigDecimal("1.5"),
|
amount = BigDecimal("1.5"),
|
||||||
|
|
@ -120,6 +120,14 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
|
||||||
amount = BigDecimal("0.001"),
|
amount = BigDecimal("0.001"),
|
||||||
decimals = 8,
|
decimals = 8,
|
||||||
),
|
),
|
||||||
|
externalTxUrl = null,
|
||||||
|
payinAddress = "payin-addr",
|
||||||
|
updatedAtMillis = TIMESTAMP,
|
||||||
|
refundAssetId = null,
|
||||||
|
refundCurrency = null,
|
||||||
|
fromAmount = BigDecimal("1.5"),
|
||||||
|
toAmount = BigDecimal("0.001"),
|
||||||
|
toActualAmount = null,
|
||||||
),
|
),
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
txInfo = null,
|
txInfo = null,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue