Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 16:33:01 +03:00
parent ef8a13cfaa
commit 2f60ccf3fc
14 changed files with 732 additions and 148 deletions

View file

@ -2051,6 +2051,8 @@
<string name="twins_recreate_toolbar">Tangem Twin</string>
<string name="twins_recreate_warning">This action is irreversible. You will not have access to the old wallet.</string>
<string name="twins_scan_twin_with_number">Tap the twin card with number %s and do not remove until the end of the operation</string>
<string name="tx_history_onramp_top_up">Top up</string>
<string name="tx_history_onramp_topped_up">Topped up</string>
<string name="unexpected_error_description">Please try again later. If the issue persists, please contact support.</string>
<string name="unexpected_error_title">Something went wrong!</string>
<string name="universal_error">We\'ve encountered an error. Error code: %s. Please contact our support.</string>

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -32,6 +33,8 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction
@ -70,55 +73,92 @@ fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier
@Composable
private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
val rowModifier = modifier
.fillMaxWidth()
.clickable(onClick = state.onClick)
.testTag(TransactionHistoryItemTestTags.ITEM)
TangemRowContainer(
modifier = rowModifier,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x3,
),
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = state.onClick)
.testTag(TransactionHistoryItemTestTags.ITEM),
) {
StatusCircle(
iconRes = state.iconRes,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10)
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
TangemRowContainer(
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x3,
),
) {
StatusCircle(
iconRes = state.iconRes,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10)
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
)
TitleText(
title = state.title,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TransactionHistoryItemTestTags.TITLE),
)
SubtitleText(
subtitle = state.subtitle,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
state.amount?.let { amount ->
AmountText(
amount = amount,
status = state.status,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TransactionHistoryItemTestTags.AMOUNT),
)
}
CurrencyText(
symbol = state.currencySymbol,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5)
.testTag(TransactionHistoryItemTestTags.CURRENCY),
)
}
state.warning?.let { warning ->
WarningLine(
warning = warning,
modifier = Modifier.padding(
start = TangemTheme.dimens2.x4,
end = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x3,
),
)
}
}
}
@Composable
private fun WarningLine(warning: TextReference, modifier: Modifier = Modifier) {
val attention = TangemTheme.colors2.text.status.attention
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Icon(
painter = painterResource(R.drawable.ic_alert_triangle_20),
contentDescription = null,
tint = attention,
modifier = Modifier.size(TangemTheme.dimens2.x5),
)
TitleText(
title = state.title,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TransactionHistoryItemTestTags.TITLE),
)
SubtitleText(
subtitle = state.subtitle,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
AmountText(
amount = state.amount,
status = state.status,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TransactionHistoryItemTestTags.AMOUNT),
)
CurrencyText(
symbol = state.currencySymbol,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5)
.testTag(TransactionHistoryItemTestTags.CURRENCY),
Text(
text = warning.resolveReference(),
color = attention,
style = TangemTheme.typography2.captionMedium12,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@ -262,6 +302,21 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
modifier = Modifier.fillMaxSize(),
)
}
is ContentSubtitle.Asset -> InlineImageSubtitle(
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.symbol),
color = tertiary,
afterIconColor = if (isFailed) tertiary else primary,
modifier = modifier,
) {
subtitle.icon?.let { iconState ->
CurrencyIcon(
state = iconState,
shouldDisplayNetwork = false,
withFixedSize = false,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@ -491,4 +546,73 @@ private fun Preview_TransactionItem_Swap() {
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionItem_Express() {
TangemThemePreviewRedesign {
PreviewColumn(
items = listOf(
TransactionItemUM.Content(
txHash = "exp-swap-u",
amount = "-390.00",
currencySymbol = "USDT",
time = "",
status = Status.Unconfirmed,
direction = Direction.OUTGOING,
onClick = {},
iconRes = R.drawable.ic_exchange_vertical_24,
title = stringReference("Swapping"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.TO,
symbol = "POL",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_custom_token_44,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
timestamp = 0L,
warning = stringReference("KYC verification required by provider"),
),
TransactionItemUM.Content(
txHash = "exp-onramp-c",
amount = "+0.006339",
currencySymbol = "BTC",
time = "",
status = Status.Confirmed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
title = stringReference("Topped up"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,
symbol = "SEK",
icon = null,
),
timestamp = 0L,
),
TransactionItemUM.Content(
txHash = "exp-onramp-f",
amount = "0.006339",
currencySymbol = "BTC",
time = "",
status = Status.Failed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
title = stringReference("Top up failed"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,
symbol = "SEK",
icon = null,
),
timestamp = 0L,
),
),
)
}
}
// endregion

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.transactions.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
@ -22,12 +23,13 @@ sealed interface TransactionItemUM {
/**
* Content state.
*
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded.
* `null` hides the numeric value while [currencySymbol] still shows.
* @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT"
*/
data class Content(
override val txHash: String,
val amount: String,
val amount: String?,
val currencySymbol: String,
val time: String,
val status: Status,
@ -37,6 +39,7 @@ sealed interface TransactionItemUM {
val title: TextReference,
val subtitle: ContentSubtitle,
val timestamp: Long,
val warning: TextReference? = null,
) : TransactionItemUM {
@Immutable
@ -95,6 +98,19 @@ sealed interface TransactionItemUM {
val deviceIconUM: DeviceIconUM,
) : ContentSubtitle
/**
* Counterparty asset ticker renders as "to/from: <icon> <SYMBOL>". Used for express rows
* (swap counterparty currency / onramp fiat), e.g. "to: ◎ POL" or "from: 🇸🇪 SEK".
*
* @property icon resolved counterparty currency icon, rendered via `CurrencyIcon`. `null` when no icon
* is available (e.g. onramp fiat carries no `CryptoCurrency`) the ticker then renders without a leading icon.
*/
data class Asset(
val direction: Direction,
val symbol: String,
val icon: CurrencyIconState?,
) : ContentSubtitle
enum class Direction { TO, FROM }
}

View file

@ -55,13 +55,13 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
payoutHash = entity.payoutHash,
fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toBigDecimalOrZero(),
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
decimals = entity.fromPrecision,
type = AmountType.FiatType(code = entity.fromCurrencyCode),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
@ -90,13 +90,13 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
payoutHash = entity.payoutHash,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
decimals = entity.from.decimals,
cryptoCurrency = value.fromCurrency,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
@ -105,7 +105,12 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
/**
* Parses a raw minimal-unit amount string from the express backend and scales it to the human-readable
* value promised by [ExpressTransactionAsset.amount] (and the onramp fiat [Amount.value]).
*/
private fun String.toScaledBigDecimal(decimals: Int): BigDecimal =
(this.toBigDecimalOrNull() ?: BigDecimal.ZERO).movePointLeft(decimals)
/**
* Active (non-terminal) RAW status values passed to the DAO `observe` queries so in-progress deals

View file

@ -31,8 +31,9 @@ internal class ExpressTxHistoryConverterTest {
assertThat(swap.txInfo).isNull()
assertThat(swap.tx.status).isEqualTo(ExpressExchangeStatus.Waiting)
assertThat(swap.createdAtMillis).isEqualTo(DateTime.parse(CREATED_AT).millis)
assertThat(swap.tx.fromAsset.amount).isEqualTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.001"))
// Raw backend amounts are scaled by decimals into the human-readable value the domain model promises.
assertThat(swap.tx.fromAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
}
@Test
@ -50,14 +51,14 @@ internal class ExpressTxHistoryConverterTest {
@Test
fun `GIVEN exchange entity with actual amount WHEN toOutgoingSwap THEN to-asset uses actual amount`() {
// Arrange
val entity = createExchangeEntity(toAmount = "0.001", toActualAmount = "0.00099")
// Arrange (raw minimal-unit amounts, to-asset decimals = 8)
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
// Assert
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
}
@Test
@ -73,17 +74,18 @@ internal class ExpressTxHistoryConverterTest {
assertThat(onramp.txInfo).isNull()
assertThat(onramp.tx.status).isEqualTo(ExpressOnrampStatus.Finished)
assertThat(onramp.tx.fromFiat.currencySymbol).isEqualTo("USD")
assertThat(onramp.tx.fromFiat.value).isEqualTo(BigDecimal("100.0"))
assertThat(onramp.tx.fromFiat.value).isEquivalentAccordingToCompareTo(BigDecimal("100"))
assertThat(onramp.tx.fromFiat.decimals).isEqualTo(2)
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
assertThat(onramp.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
}
private fun createExchangeEntity(
payinHash: String? = "payin",
payoutHash: String? = "payout",
status: String = "waiting",
toAmount: String = "0.001",
// Raw minimal-unit amount (to-asset decimals = 8) → 0.001
toAmount: String = "100000",
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
@ -111,7 +113,8 @@ internal class ExpressTxHistoryConverterTest {
contractAddress = "",
network = "ethereum",
decimals = 18,
amount = "1.5",
// Raw minimal-unit amount (decimals = 18) → 1.5
amount = "1500000000000000000",
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
@ -139,13 +142,15 @@ internal class ExpressTxHistoryConverterTest {
createdAt = CREATED_AT,
updatedAt = CREATED_AT,
fromCurrencyCode = "USD",
fromAmount = "100.0",
// Raw minimal-unit fiat amount (precision = 2) → 100
fromAmount = "10000",
fromPrecision = 2,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = "0xtoken",
network = "ethereum",
decimals = 18,
amount = "0.5",
// Raw minimal-unit amount (decimals = 18) → 0.5
amount = "500000000000000000",
actualAmount = null,
),
paymentMethod = "card",

View file

@ -7,14 +7,14 @@ import java.math.BigDecimal
* A crypto asset leg of an express operation: which asset and how much of it moved.
*
* @property id The asset identifier (network id + contract address).
* @property amount Human-readable amount (already scaled by [decimals]).
* @property amount Human-readable amount (already scaled by [decimals]); `null` when the backend provided no amount.
* @property decimals The asset's decimals.
* @property cryptoCurrency The portfolio [CryptoCurrency] this asset was resolved to (matched by network id +
* contract address across all accounts). `null` when no portfolio currency matched and no fallback could be built.
*/
data class ExpressTransactionAsset(
val id: ExpressAsset.ID,
val amount: BigDecimal,
val amount: BigDecimal?,
val decimals: Int,
val cryptoCurrency: CryptoCurrency? = null,
)

View file

@ -14,6 +14,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTransactionsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
@ -47,6 +48,7 @@ internal class ExpressStatusFactory @AssistedInject constructor(
onrampStatusFactory: OnrampStatusFactory.Factory,
exchangeStatusFactory: ExchangeStatusFactory.Factory,
private val swapFeatureToggles: SwapFeatureToggles,
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
) {
private val exchangeStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
@ -106,12 +108,17 @@ internal class ExpressStatusFactory @AssistedInject constructor(
if (currentTx is ExchangeUM && currentTx.activeStatus == ExchangeStatus.Finished) {
updateBalance(currentTx.toCryptoCurrency)
}
val expressTxsToDisplay = expressTxs.filterNot {
when (it) {
is ExpressTransactionStateUM.OnrampUM -> it.activeStatus.isHidden
else -> false
}
}.toPersistentList()
val expressTxsToDisplay = if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
persistentListOf()
} else {
expressTxs.filterNot {
when (it) {
is ExpressTransactionStateUM.OnrampUM -> it.activeStatus.isHidden
else -> false
}
}.toPersistentList()
}
return state.copy(
transactions = expressTxs,
transactionsToDisplay = expressTxsToDisplay,

View file

@ -0,0 +1,191 @@
package com.tangem.features.txhistory.converter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Maps an [ExpressTx] (swap / onramp) row directly to [TransactionItemUM.Content].
*
* The viewed leg is [CryptoCurrency] ([currency], the token-details currency): outgoing swap shows the pay-in
* (`from`) amount with a minus, incoming swap / onramp shows the received (`to`) amount with a plus. The 26 typed
* express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI).
*
* The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap);
* onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click opens the
* explorer.
*/
internal class ExpressTxToTransactionItemUMConverter(
private val currency: CryptoCurrency,
private val txHistoryUiActions: TxHistoryUiActions,
) : Converter<ExpressTx, TransactionItemUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: ExpressTx): TransactionItemUM = when (value) {
is ExpressTx.Swap -> swapContent(value)
is ExpressTx.Onramp -> onrampContent(value)
}
private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content {
val status = swap.tx.status.toUiStatus()
val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount
val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset
val prefix = when {
status is Status.Failed -> ""
swap.isOutgoing -> StringsSigns.MINUS
else -> StringsSigns.PLUS
}
return buildContent(
tx = swap,
status = status,
amount = formatAmount(viewedAmount, prefix),
direction = if (swap.isOutgoing) RowDirection.OUTGOING else RowDirection.INCOMING,
iconRes = R.drawable.ic_exchange_vertical_24,
title = swapTitle(status),
subtitle = ContentSubtitle.Asset(
direction = if (swap.isOutgoing) SubtitleDirection.TO else SubtitleDirection.FROM,
symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId,
icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert),
),
// TODO: replace null to warning logic.
warning = null,
)
}
private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content {
val status = onramp.tx.status.toUiStatus()
val prefix = when {
status is Status.Failed -> ""
status is Status.Confirmed -> StringsSigns.PLUS
else -> StringsSigns.TILDE_SIGN
}
return buildContent(
tx = onramp,
status = status,
amount = formatAmount(onramp.tx.toAsset.amount, prefix),
direction = RowDirection.INCOMING,
iconRes = R.drawable.ic_tangem_card_24,
title = onrampTitle(status),
subtitle = ContentSubtitle.Asset(
direction = SubtitleDirection.FROM,
symbol = onramp.tx.fromFiat.currencySymbol,
// TODO: fiat carries no OnrampCurrency, so no icon yet — render with a fiat country flag once available.
icon = null,
),
// TODO: replace null to warning logic.
warning = null,
)
}
@Suppress("LongParameterList")
private fun buildContent(
tx: ExpressTx,
status: Status,
amount: String?,
direction: RowDirection,
iconRes: Int,
title: TextReference,
subtitle: ContentSubtitle,
warning: TextReference?,
): TransactionItemUM.Content {
val explorerHash = tx.matchHash ?: tx.txId
return TransactionItemUM.Content(
txHash = explorerHash,
amount = amount,
currencySymbol = currency.symbol,
time = tx.timestampMillis.toTimeFormat(),
status = status,
direction = direction,
onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) },
iconRes = iconRes,
title = title,
subtitle = subtitle,
timestamp = tx.timestampMillis,
warning = warning,
)
}
private fun formatAmount(amount: BigDecimal?, prefix: String): String? =
amount?.let { prefix + it.format { crypto(symbol = "", decimals = currency.decimals) }.trim() }
private fun swapTitle(status: Status): TextReference = when (status) {
is Status.Confirmed -> resourceReference(R.string.common_swapped)
is Status.Unconfirmed -> resourceReference(R.string.common_swapping)
is Status.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(R.string.common_swapping)))
}
private fun onrampTitle(status: Status): TextReference = when (status) {
is Status.Confirmed -> resourceReference(R.string.tx_history_onramp_topped_up)
is Status.Unconfirmed -> resourceReference(R.string.tx_history_onramp_top_up)
is Status.Failed -> resourceReference(
R.string.common_action_failed,
wrappedList(resourceReference(R.string.tx_history_onramp_top_up)),
)
}
}
// region Status mapping
/**
* Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]),
* the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) Failed, everything in flight
* (incl. [Verifying] and [Paused]) Unconfirmed.
*/
private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) {
ExpressExchangeStatus.Finished -> Status.Confirmed
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Refunded,
ExpressExchangeStatus.Expired,
ExpressExchangeStatus.Unknown,
-> Status.Failed
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
ExpressExchangeStatus.WaitingTxHash,
ExpressExchangeStatus.Confirming,
ExpressExchangeStatus.Exchanging,
ExpressExchangeStatus.Sending,
ExpressExchangeStatus.Verifying,
ExpressExchangeStatus.Paused,
-> Status.Unconfirmed
}
private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) {
ExpressOnrampStatus.Finished -> Status.Confirmed
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
ExpressOnrampStatus.Unknown,
-> Status.Failed
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
ExpressOnrampStatus.PaymentProcessing,
ExpressOnrampStatus.Verifying,
ExpressOnrampStatus.Paid,
ExpressOnrampStatus.Sending,
ExpressOnrampStatus.Paused,
-> Status.Unconfirmed
}
// endregion

View file

@ -4,21 +4,20 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.utils.toSyntheticTxInfo
import com.tangem.utils.converter.Converter
/**
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM], delegating to the on-chain
* [TxHistoryItemToTransactionItemUMConverter]: on-chain rows convert their `TxInfo` directly, express
* rows convert a synthesized `TxInfo` view (see [toSyntheticTxInfo]).
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via
* [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter].
*/
internal class TxHistoryInfoToTransactionItemUMConverter(
private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter,
private val expressConverter: ExpressTxToTransactionItemUMConverter,
) : Converter<TxHistoryInfo, TransactionItemUM> {
override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) {
is OnChainTx -> convertOnChain(value)
is ExpressTx -> txInfoConverter.convert(value.toSyntheticTxInfo())
is ExpressTx -> expressConverter.convert(value)
}
private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) {

View file

@ -28,6 +28,7 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
@ -39,6 +40,7 @@ import com.tangem.features.txhistory.utils.HistoryTxListManager
import com.tangem.features.txhistory.utils.TxHistoryListManager
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.ImmutableList
@ -100,9 +102,11 @@ internal class TxHistoryModel @Inject constructor(
emptyFlow()
}
@RemoveWithToggle("APP_REDESIGN_ENABLED")
private val legacyTxHistoryItemConverter =
TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this)
@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED")
private val txHistoryListManager: TxHistoryListManager? = if (!txHistoryFeatureToggle.isNewTxHistoryEnabled) {
TxHistoryListManager(
repository = repository,
@ -193,7 +197,6 @@ internal class TxHistoryModel @Inject constructor(
}
}
// Temporary: express rows are mapped to UI via a synthesized TxInfo (see ExpressTx.toSyntheticTxInfo).
private fun buildUiItems(
merged: List<TxHistoryInfo>,
lookup: TxHistoryLookupContext,
@ -204,6 +207,10 @@ internal class TxHistoryModel @Inject constructor(
txHistoryUiActions = this,
lookupContext = lookup,
),
expressConverter = ExpressTxToTransactionItemUMConverter(
currency = params.currency,
txHistoryUiActions = this,
),
)
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()

View file

@ -1,7 +1,5 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
@ -50,48 +48,4 @@ private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx {
is ExpressTx.Swap -> copy(txInfo = matched)
is ExpressTx.Onramp -> copy(txInfo = matched)
}
}
/**
* Synthesizes a [TxInfo] view of an express op so it can be rendered by the existing
* [com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter]. Rendered as a
* [TxInfo.TransactionType.Swap] for now (onramp included). The amount is the viewed-currency leg.
*/
internal fun ExpressTx.toSyntheticTxInfo(): TxInfo {
val viewedAmount = when (this) {
is ExpressTx.Swap -> if (isOutgoing) tx.fromAsset.amount else tx.toAsset.amount
is ExpressTx.Onramp -> tx.toAsset.amount
}
val isOutgoing = when (this) {
is ExpressTx.Swap -> this.isOutgoing
is ExpressTx.Onramp -> false
}
return TxInfo(
// matchHash is the on-chain hash (== the matched leg's hash, enables the explorer link); else txId.
txHash = matchHash ?: txId,
timestampInMillis = timestampMillis,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(address = "")),
sourceType = TxInfo.SourceType.Single(address = ""),
interactionAddressType = null,
status = toTransactionStatus(),
type = TxInfo.TransactionType.Swap,
amount = viewedAmount,
)
}
/**
* Maps the typed express status to the on-chain-shaped [TxInfo.TransactionStatus] used by the UI:
* the single success state (`Finished`) Confirmed, any other terminal state Failed, in-progress Unconfirmed.
*/
private fun ExpressTx.toTransactionStatus(): TxInfo.TransactionStatus {
val isFinished = when (this) {
is ExpressTx.Swap -> tx.status == ExpressExchangeStatus.Finished
is ExpressTx.Onramp -> tx.status == ExpressOnrampStatus.Finished
}
return when {
isFinished -> TxInfo.TransactionStatus.Confirmed
isTerminal -> TxInfo.TransactionStatus.Failed
else -> TxInfo.TransactionStatus.Unconfirmed
}
}

View file

@ -0,0 +1,289 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressTxToTransactionItemUMConverterTest {
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18)
private val converter = ExpressTxToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
)
// region Status → bucket
@Test
fun `GIVEN every swap status WHEN convert THEN mapped to expected status bucket`() {
val cases = mapOf(
ExpressExchangeStatus.Finished to Status.Confirmed,
ExpressExchangeStatus.Failed to Status.Failed,
ExpressExchangeStatus.TxFailed to Status.Failed,
ExpressExchangeStatus.Refunded to Status.Failed,
ExpressExchangeStatus.Expired to Status.Failed,
ExpressExchangeStatus.Unknown to Status.Failed,
ExpressExchangeStatus.Preview to Status.Unconfirmed,
ExpressExchangeStatus.Created to Status.Unconfirmed,
ExpressExchangeStatus.ExchangeTxSent to Status.Unconfirmed,
ExpressExchangeStatus.Waiting to Status.Unconfirmed,
ExpressExchangeStatus.WaitingTxHash to Status.Unconfirmed,
ExpressExchangeStatus.Confirming to Status.Unconfirmed,
ExpressExchangeStatus.Exchanging to Status.Unconfirmed,
ExpressExchangeStatus.Sending to Status.Unconfirmed,
ExpressExchangeStatus.Verifying to Status.Unconfirmed,
ExpressExchangeStatus.Paused to Status.Unconfirmed,
)
// every enum entry is covered (guards against new statuses silently falling through)
assertThat(cases.keys).containsExactlyElementsIn(ExpressExchangeStatus.entries)
cases.forEach { (status, expected) ->
val result = converter.convert(createSwap(status = status)) as TransactionItemUM.Content
assertWithMessage(status.name).that(result.status).isEqualTo(expected)
}
}
@Test
fun `GIVEN every onramp status WHEN convert THEN mapped to expected status bucket`() {
val cases = mapOf(
ExpressOnrampStatus.Finished to Status.Confirmed,
ExpressOnrampStatus.Failed to Status.Failed,
ExpressOnrampStatus.Expired to Status.Failed,
ExpressOnrampStatus.Unknown to Status.Failed,
ExpressOnrampStatus.Created to Status.Unconfirmed,
ExpressOnrampStatus.WaitingForPayment to Status.Unconfirmed,
ExpressOnrampStatus.PaymentProcessing to Status.Unconfirmed,
ExpressOnrampStatus.Verifying to Status.Unconfirmed,
ExpressOnrampStatus.Paid to Status.Unconfirmed,
ExpressOnrampStatus.Sending to Status.Unconfirmed,
ExpressOnrampStatus.Paused to Status.Unconfirmed,
)
assertThat(cases.keys).containsExactlyElementsIn(ExpressOnrampStatus.entries)
cases.forEach { (status, expected) ->
val result = converter.convert(createOnramp(status = status)) as TransactionItemUM.Content
assertWithMessage(status.name).that(result.status).isEqualTo(expected)
}
}
// endregion
// region Amount sign / prefix
@Test
fun `GIVEN outgoing swap WHEN convert THEN amount is negative from-leg`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true),
) as TransactionItemUM.Content
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING)
assertThat(result.amount).startsWith("-")
assertThat(result.amount).contains("1.5")
}
@Test
fun `GIVEN incoming swap WHEN convert THEN amount is positive to-leg`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = false),
) as TransactionItemUM.Content
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING)
assertThat(result.amount).startsWith("+")
assertThat(result.amount).contains("0.001")
}
@Test
fun `GIVEN finished onramp WHEN convert THEN amount prefixed with plus`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content
assertThat(result.amount).startsWith("+")
}
@Test
fun `GIVEN in-progress onramp WHEN convert THEN amount prefixed with tilde`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
assertThat(result.amount).startsWith("~")
}
@Test
fun `GIVEN failed onramp WHEN convert THEN amount has no sign prefix`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Failed)) as TransactionItemUM.Content
assertThat(requireNotNull(result.amount).first())
.isIn(listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'))
}
@Test
fun `GIVEN swap with null viewed amount WHEN convert THEN amount is null`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true, fromAmount = null),
) as TransactionItemUM.Content
assertThat(result.amount).isNull()
}
@Test
fun `GIVEN onramp with null amount WHEN convert THEN amount is null`() {
val result = converter.convert(
createOnramp(status = ExpressOnrampStatus.Sending, toAmount = null),
) as TransactionItemUM.Content
assertThat(result.amount).isNull()
}
// endregion
// region Title / subtitle / warning / click
@Test
fun `GIVEN swap statuses WHEN convert THEN status-aware title`() {
val swapping = converter.convert(createSwap(status = ExpressExchangeStatus.Waiting)) as TransactionItemUM.Content
val swapped = converter.convert(createSwap(status = ExpressExchangeStatus.Finished)) as TransactionItemUM.Content
assertThat(swapping.title).isEqualTo(resourceReference(R.string.common_swapping))
assertThat(swapped.title).isEqualTo(resourceReference(R.string.common_swapped))
}
@Test
fun `GIVEN onramp statuses WHEN convert THEN status-aware title`() {
val topUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
val toppedUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content
assertThat(topUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_top_up))
assertThat(toppedUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_topped_up))
}
@Test
fun `GIVEN outgoing swap WHEN convert THEN subtitle shows TO counterparty ticker`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true),
) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Asset
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
assertThat(subtitle.symbol).isEqualTo("btc") // mock: counterparty (to-leg) networkId
}
@Test
fun `GIVEN onramp WHEN convert THEN subtitle shows FROM fiat code`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Asset
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
assertThat(subtitle.symbol).isEqualTo("SEK")
}
@Test
fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true),
) as TransactionItemUM.Content
result.onClick()
verify { txHistoryUiActions.openTxInExplorer("0xhash") }
}
// endregion
private fun createSwap(
status: ExpressExchangeStatus,
matchHash: String? = null,
isOutgoing: Boolean = true,
fromAmount: BigDecimal? = BigDecimal("1.5"),
toAmount: BigDecimal? = BigDecimal("0.001"),
) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = status,
createdAtMillis = 100,
provider = null,
payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing },
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = fromAmount,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = toAmount,
decimals = 8,
),
),
isOutgoing = isOutgoing,
txInfo = null,
)
private fun createOnramp(
status: ExpressOnrampStatus,
toAmount: BigDecimal? = BigDecimal("0.006339"),
) = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
),
txInfo = null,
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
),
network = Network(
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
name = "Ethereum",
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Ethereum",
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}

View file

@ -161,8 +161,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
assertThat(result.subtitle).isEqualTo(
ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)),
)
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
}
@Test
@ -514,7 +514,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isTrue()
}
@Test
@ -528,7 +528,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isTrue()
}
@Test
@ -543,8 +543,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
}
@Test
@ -558,8 +558,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
}
// endregion

View file

@ -85,21 +85,6 @@ internal class TxHistoryInfoMergerTest {
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
}
@Test
fun `GIVEN outgoing swap WHEN toSyntheticTxInfo THEN viewed from-leg amount and swap type`() {
// Arrange
val swap = createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = true)
// Act
val txInfo = swap.toSyntheticTxInfo()
// Assert
assertThat(txInfo.isOutgoing).isTrue()
assertThat(txInfo.amount).isEqualTo(BigDecimal("1.5"))
assertThat(txInfo.type).isEqualTo(TxInfo.TransactionType.Swap)
assertThat(txInfo.status).isEqualTo(TxInfo.TransactionStatus.Unconfirmed)
}
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
txHash = txHash,
timestampInMillis = timestamp,