diff --git a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt
index 70dbb5ec33..ca6fa0bed6 100644
--- a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt
+++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt
@@ -22,6 +22,7 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul
private val factory = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains())
+ val bitcoin by lazy { createCoin(Blockchain.Bitcoin) }
val cardano by lazy { createCoin(blockchain = Blockchain.Cardano) }
val chia by lazy { createCoin(Blockchain.Chia) }
val ethereum by lazy { createCoin(Blockchain.Ethereum) }
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index a0ebd7f24c..0f66cd5146 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -418,6 +418,7 @@
Privacy Policy
%1$s-%2$s
%1$s — %2$s
+ Rate
Read more
Receive
Received
diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt
index d911aa525b..8d001e036c 100644
--- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt
+++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt
@@ -66,6 +66,7 @@ internal class ExpressOnrampConverter : Converter Unit,
- /** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */
+ private val onGoToProvider: (String) -> Unit,
private val ownAddresses: Set = emptySet(),
) : Converter {
@@ -159,7 +162,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
isFaded = status is Status.Failed,
),
statusBanner = swap.tx.status.toStatusBannerUM(),
- rows = swap.toInfoRows(),
+ rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()),
+ providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()),
)
}
@@ -185,7 +189,19 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
isFaded = status is Status.Failed,
),
statusBanner = onramp.tx.status.toStatusBannerUM(),
- rows = onramp.toInfoRows(),
+ rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()),
+ providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()),
+ )
+ }
+
+ /** Opens the deal's provider page on tap; `null` when the deal has no provider link. */
+ private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } }
+
+ private fun providerButton(url: String?, @StringRes label: Int?): TxHistoryDetailsUM.ProviderButtonUM? {
+ if (url == null || label == null) return null
+ return TxHistoryDetailsUM.ProviderButtonUM(
+ text = resourceReference(label),
+ onClick = { onGoToProvider(url) },
)
}
@@ -285,6 +301,32 @@ private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBan
ExpressOnrampStatus.Unknown -> null
}
+/**
+ * Label of the bottom CTA for an express swap, or `null` for statuses that need no provider action. The KYC
+ * [Verifying][ExpressExchangeStatus.Verifying] state sends the user to verification; the failure terminals send them
+ * to the provider (to track / refund). Mirrors the failed/verification banners (the existing express block uses the
+ * same per-tx link for both).
+ */
+@StringRes
+private fun ExpressExchangeStatus.providerButtonLabel(): Int? = when (this) {
+ ExpressExchangeStatus.Verifying -> R.string.common_go_to_verification
+ ExpressExchangeStatus.Failed,
+ ExpressExchangeStatus.TxFailed,
+ ExpressExchangeStatus.Expired,
+ -> R.string.common_go_to_provider
+ else -> null
+}
+
+/** Label of the bottom CTA for an express onramp, or `null` for statuses that need no provider action. */
+@StringRes
+private fun ExpressOnrampStatus.providerButtonLabel(): Int? = when (this) {
+ ExpressOnrampStatus.Verifying -> R.string.common_go_to_verification
+ ExpressOnrampStatus.Failed,
+ ExpressOnrampStatus.Expired,
+ -> R.string.common_go_to_provider
+ else -> null
+}
+
private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(title),
@@ -333,26 +375,34 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme
// endregion
-// region Info rows (network fee)
+// region Info rows (provider / rate / network fee)
/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */
private fun TxInfo.toInfoRows(): ImmutableList = listOfNotNull(feeRow()).toImmutableList()
/**
- * Detail rows of an express op: the [provider] row (its name) followed by the network-fee row from the matched on-chain
- * leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present.
- * (Rate is not surfaced yet — no data.)
+ * Detail rows of an express op, in order: the [provider] row (its name), the effective-[rateRow] row, then the
+ * network-fee row from the matched on-chain leg. Each is dropped when its data is absent — the provider while it is
+ * unresolved, the rate while an amount is missing / non-positive (see [swapRateRow] / [onrampRateRow]), the fee while
+ * no on-chain leg / fee is present.
*/
-private fun ExpressTx.toInfoRows(): ImmutableList = buildList {
- provider?.let { add(it.providerRow()) }
+private fun ExpressTx.toInfoRows(
+ onProviderClick: (() -> Unit)?,
+ rateRow: TxHistoryDetailsUM.InfoRowUM?,
+): ImmutableList = buildList {
+ provider?.let { add(it.providerRow(onProviderClick)) }
+ rateRow?.let { add(it) }
addAll(txInfo.toInfoRows())
}.toImmutableList()
-private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
- label = resourceReference(R.string.express_provider),
- value = stringReference(name),
- trailingIconRes = R.drawable.ic_arrow_top_right_24,
-)
+private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM =
+ TxHistoryDetailsUM.InfoRowUM(
+ label = resourceReference(R.string.express_provider),
+ value = stringReference(name),
+ // The arrow link affordance is shown only when the row opens the provider page.
+ trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 },
+ onClick = onClick,
+ )
/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */
private fun OnChainTx?.toInfoRows(): ImmutableList =
@@ -371,6 +421,71 @@ private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? {
// endregion
+// region Rate row
+
+private const val RATE_MAX_DECIMALS = 8
+private const val RATE_IF_ZERO_DECIMALS = 2
+
+/**
+ * Effective swap rate row `1 {from} ≈ {x} {to}`, computed on the fly as `x = toAmount / fromAmount` (`toAmount` is
+ * already the actual-or-expected payout — the data layer coalesces `actualAmount ?: amount`). Hidden (`null`) when an
+ * amount is missing or non-positive — there is then no rate to show and division by zero is avoided.
+ */
+private fun ExchangeTransaction.swapRateRow(): TxHistoryDetailsUM.InfoRowUM? {
+ val fromAmount = fromAsset.amount.takeIfPositive() ?: return null
+ val toAmount = toAsset.amount.takeIfPositive() ?: return null
+ val rate = toAmount.divide(fromAmount, rateScale(toAsset.decimals), RoundingMode.HALF_UP)
+ val baseSymbol = fromAsset.cryptoCurrency?.symbol ?: fromAsset.id.networkId
+ val quoteSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
+ val value = rateText(
+ base = oneOf(baseSymbol),
+ quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) },
+ )
+ return rateRowUM(value)
+}
+
+/**
+ * Effective onramp rate row `1 {crypto} ≈ {x} {fiat}`, computed on the fly as `x = fiatPaid / cryptoReceived`. The API's
+ * nominal `rate` / `rate_usd` are intentionally ignored to avoid UI drift from hidden fees. Hidden (`null`) when an
+ * amount is missing or non-positive.
+ */
+private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? {
+ val fiatPaid = fromFiat.value.takeIfPositive() ?: return null
+ val cryptoReceived = toAsset.amount.takeIfPositive() ?: return null
+ // Divide at full precision; the fiat formatter then rounds the rate to the currency's display scale.
+ val rate = fiatPaid.divide(cryptoReceived, RATE_MAX_DECIMALS, RoundingMode.HALF_UP)
+ val cryptoSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
+ val fiatCode = (fromFiat.type as? AmountType.FiatType)?.code ?: fromFiat.currencySymbol
+ val value = rateText(
+ base = oneOf(cryptoSymbol),
+ quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) },
+ )
+ return rateRowUM(value)
+}
+
+private fun rateRowUM(value: String): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
+ label = resourceReference(R.string.common_rate),
+ value = stringReference(value),
+)
+
+/** Division scale: the quote's decimals, capped at [RATE_MAX_DECIMALS]; a zero-decimal quote still shows two. */
+private fun rateScale(quoteDecimals: Int): Int =
+ (if (quoteDecimals == 0) RATE_IF_ZERO_DECIMALS else quoteDecimals).coerceAtMost(RATE_MAX_DECIMALS)
+
+/**
+ * Leading `1 {symbol}` of the rate, e.g. `1 POL` — number-first, matching the amount legs (the crypto formatter forces a
+ * two-decimal minimum, so the literal `1` is built directly rather than via [crypto]).
+ */
+private fun oneOf(symbol: String): String = "1${StringsSigns.NON_BREAKING_SPACE}$symbol"
+
+private fun rateText(base: String, quote: String): String {
+ return "${base.trim()} ${StringsSigns.APPROXIMATE} ${quote.trim()}"
+}
+
+private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigDecimal.ZERO }
+
+// endregion
+
// region Amount building helpers
/** Leading sign of the pay-in / "You send" leg: `−` while in flight or settled, dropped on a failed deal. */
diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt
index 1a6e454f79..179cd1d3c6 100644
--- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt
+++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt
@@ -38,9 +38,10 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
*
* [from] ("You send") → [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a
* future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is
- * the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its
- * name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped
- * when its data is unavailable (rate is not surfaced yet — no data).
+ * the express status plaque under the block, `null` until status is known. [rows] carries, in order, the provider
+ * row (its name), the effective-rate row, and the network-fee row pulled from the matched on-chain leg
+ * (`ExpressTx.txInfo`); each is dropped when its data is unavailable. [providerButton] is the bottom "Go to
+ * provider" / "Go to verification" CTA, `null` unless the deal is on a provider-actionable terminal with a link.
*/
data class TwoAssets(
override val header: HeaderUM,
@@ -48,6 +49,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
val rows: ImmutableList = persistentListOf(),
+ val providerButton: ProviderButtonUM? = null,
) : TxHistoryDetailsUM
/**
@@ -69,6 +71,19 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
enum class Severity { Info, Success, Error, Warning }
}
+ /**
+ * Bottom call-to-action of the two-asset card, shown only on the provider-actionable terminals of an express deal
+ * (failed / expired → "Go to provider"; KYC verification → "Go to verification") and only when the deal carries a
+ * provider link. [onClick] opens that link (`ExpressTx.externalTxUrl`).
+ *
+ * @property text Button label ("Go to provider" / "Go to verification").
+ * @property onClick Opens the provider's page for this deal.
+ */
+ data class ProviderButtonUM(
+ val text: TextReference,
+ val onClick: () -> Unit,
+ )
+
/**
* One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing
* side. [owner] `null` → plain label ("You send"); non-null → "From"/"To" prefix plus the resolved own account /
@@ -133,11 +148,14 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
*
* [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the
* provider row); `null` leaves the trailing slot text-only.
+ *
+ * [onClick] makes the row tappable (e.g. the provider row opens the provider page); `null` makes it non-interactive.
*/
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
@DrawableRes val trailingIconRes: Int? = null,
+ val onClick: (() -> Unit)? = null,
)
/**
diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt
index 52c3732eab..9837280310 100644
--- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt
+++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
+import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
@@ -25,6 +26,7 @@ import javax.inject.Inject
internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
+ private val urlOpener: UrlOpener,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
@@ -43,6 +45,7 @@ internal class TxHistoryDetailsModel @Inject constructor(
TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
+ onGoToProvider = urlOpener::openUrl,
ownAddresses = ownAddresses,
).convert(txInfo)
}
diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt
index 14ed97a642..203f473ed4 100644
--- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt
+++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt
@@ -11,8 +11,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.tangem.core.ui.ds.image.TangemIconUM
+import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
+import com.tangem.core.ui.res.generated.icons.Icons
+import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
@Composable
@@ -76,6 +80,18 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
+ // Bottom "Go to provider" / "Go to verification" CTA — only on a provider-actionable terminal with a link.
+ state.providerButton?.let { providerButton ->
+ TangemButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ variant = TangemButton.Variant.Primary,
+ text = providerButton.text,
+ iconEnd = TangemIconUM.Icon(Icons.ic_chevron_right_20),
+ onClick = providerButton.onClick,
+ )
+ }
}
}
diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt
index 3341e7358b..66e5ebf47f 100644
--- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt
+++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt
@@ -54,7 +54,8 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, modifier:
rows.forEachIndexed { index, row ->
TangemRow(
divider = index < lastIndex,
- contentLead = TangemRowContentLead.Start,
+ contentLead = TangemRowContentLead.End,
+ onClick = row.onClick,
titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) },
valueSlot = {
Row(
diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt
index bf58d7e39c..ce6000cd73 100644
--- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt
+++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt
@@ -53,6 +53,15 @@ private fun TxHistoryDetailsModalBottomSheetContentPreview() {
}
}
+@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
+@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun TxHistoryDetailsModalBottomSheetContentTwoAssetsPreview() {
+ TangemThemePreviewRedesign {
+ TxHistoryDetailsModalBottomSheetContent(state = previewTwoAssets(), onDismiss = {})
+ }
+}
+
/** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */
private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
header = TxHistoryDetailsUM.HeaderUM(
@@ -85,4 +94,57 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
),
)
+/** Failed swap exercising the two-asset body: both legs, the error status banner, provider link row and the CTA. */
+private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets(
+ header = TxHistoryDetailsUM.HeaderUM(
+ iconRes = R.drawable.ic_exchange_vertical_24,
+ status = Status.Failed,
+ title = stringReference("Swap"),
+ subtitle = stringReference("Jan 20 2026, 9:24 PM"),
+ ),
+ from = TxHistoryDetailsUM.AssetUM(
+ label = stringReference("You send"),
+ owner = null,
+ amount = stringReference("- 1.5 ETH"),
+ currencyIcon = CurrencyIconState.CoinIcon(
+ url = null,
+ fallbackResId = R.drawable.img_eth_22,
+ isGrayscale = false,
+ shouldShowCustomBadge = false,
+ ),
+ isFaded = true,
+ ),
+ to = TxHistoryDetailsUM.AssetUM(
+ label = stringReference("You receive"),
+ owner = null,
+ amount = stringReference("+ 0.001 BTC"),
+ currencyIcon = CurrencyIconState.CoinIcon(
+ url = null,
+ fallbackResId = R.drawable.img_btc_22,
+ isGrayscale = false,
+ shouldShowCustomBadge = false,
+ ),
+ isFaded = true,
+ ),
+ statusBanner = TxHistoryDetailsUM.StatusBannerUM(
+ severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
+ title = stringReference("Failed"),
+ subtitle = stringReference("Funds will be refunded by the provider"),
+ isLoading = false,
+ ),
+ rows = persistentListOf(
+ TxHistoryDetailsUM.InfoRowUM(
+ label = stringReference("Provider"),
+ value = stringReference("Changelly"),
+ trailingIconRes = R.drawable.ic_arrow_top_right_24,
+ onClick = {},
+ ),
+ TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
+ ),
+ providerButton = TxHistoryDetailsUM.ProviderButtonUM(
+ text = stringReference("Go to provider"),
+ onClick = {},
+ ),
+)
+
// endregion
\ No newline at end of file
diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt
index 505744835a..e92ab06cf2 100644
--- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt
+++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt
@@ -38,15 +38,23 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
- private val currency = MockCryptoCurrencyFactory().ethereum
+ private val mockCurrencyFactory = MockCryptoCurrencyFactory()
+ private val currency = mockCurrencyFactory.ethereum
+
+ // The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id.
+ private val bitcoin = mockCurrencyFactory.bitcoin
private val copiedAddresses = mutableListOf()
+ private val openedUrls = mutableListOf()
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
+ onGoToProvider = openedUrls::add,
)
@BeforeEach
fun setUp() {
+ copiedAddresses.clear()
+ openedUrls.clear()
// The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern,
// which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs.
mockkStatic(DateFormat::class)
@@ -136,6 +144,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
+ onGoToProvider = openedUrls::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
@@ -157,6 +166,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
+ onGoToProvider = openedUrls::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
@@ -472,24 +482,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets
- // Assert
- assertThat(result.rows).hasSize(1)
- assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
+ // Assert — no provider in the fixture, so rate then the on-chain leg's network fee.
+ assertThat(result.rows.map { it.label }).containsExactly(
+ resourceReference(R.string.common_rate),
+ resourceReference(R.string.common_network_fee_title),
+ ).inOrder()
}
@Test
- fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() {
+ fun `GIVEN express swap with provider and url WHEN convert THEN provider row links to the url`() {
// Act
+ val result = converter.convert(
+ expressSwap(
+ status = ExpressExchangeStatus.Finished,
+ provider = provider(name = "Mercuryo"),
+ externalTxUrl = EXTERNAL_URL,
+ ),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert — provider then rate (no on-chain leg, so no fee row).
+ assertThat(result.rows.map { it.label }).containsExactly(
+ resourceReference(R.string.express_provider),
+ resourceReference(R.string.common_rate),
+ ).inOrder()
+ val providerRow = result.rows.first()
+ assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider))
+ assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
+ assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
+ providerRow.onClick?.invoke()
+ assertThat(openedUrls).containsExactly(EXTERNAL_URL)
+ }
+
+ @Test
+ fun `GIVEN express swap with provider but no url WHEN convert THEN provider row has no link`() {
+ // Act — the provider supplies no link (e.g. DEX), so the row is plain text.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")),
) as TxHistoryDetailsUM.TwoAssets
// Assert
- assertThat(result.rows).hasSize(1)
val providerRow = result.rows.first()
- assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider))
assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
- assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
+ assertThat(providerRow.trailingIconRes).isNull()
+ assertThat(providerRow.onClick).isNull()
}
@Test
@@ -508,10 +543,61 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
+ resourceReference(R.string.common_rate),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
+ @Test
+ fun `GIVEN express swap with both amounts WHEN convert THEN rate row 1 from approx to follows provider`() {
+ // Act — no on-chain leg, so the rows are provider then rate.
+ val result = converter.convert(
+ expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly")),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ assertThat(result.rows.map { it.label }).containsExactly(
+ resourceReference(R.string.express_provider),
+ resourceReference(R.string.common_rate),
+ ).inOrder()
+ val rate = result.rows[1].value.resolveString()
+ // 0.001 BTC / 1.5 ETH ≈ 0.00066667; base falls back to the unresolved from-leg network id, quote to BTC.
+ assertThat(rate).startsWith("1")
+ assertThat(rate).contains("≈")
+ assertThat(rate).contains("ethereum")
+ assertThat(rate).contains("BTC")
+ }
+
+ @Test
+ fun `GIVEN express swap with non-positive amount WHEN convert THEN no rate row`() {
+ // Arrange — a zero pay-in makes the rate undefined; the row is dropped (division-by-zero guard).
+ val base = expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly"))
+ val swap = base.copy(tx = base.tx.copy(fromAsset = base.tx.fromAsset.copy(amount = BigDecimal.ZERO)))
+
+ // Act
+ val result = converter.convert(swap) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert — only the provider row remains.
+ assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.express_provider))
+ }
+
+ @Test
+ fun `GIVEN express onramp with both amounts WHEN convert THEN rate row 1 crypto approx fiat`() {
+ // Act
+ val result = converter.convert(
+ expressOnramp(status = ExpressOnrampStatus.Finished),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert — onramp has no provider in the fixture, so the only row is the rate.
+ assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.common_rate))
+ val rate = result.rows.first().value.resolveString()
+ // 100 SEK / 0.006 BTC ≈ 16,666.67 SEK; base is the resolved crypto symbol (BTC).
+ assertThat(rate).startsWith("1")
+ assertThat(rate).contains("≈")
+ assertThat(rate).contains("BTC")
+ assertThat(rate).contains("SEK")
+ }
+
@Test
fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() {
// Act
@@ -587,6 +673,67 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(result.statusBanner).isNull()
}
+ @Test
+ fun `GIVEN failed express swap with url WHEN convert THEN go-to-provider button opening the url`() {
+ // Act
+ val result = converter.convert(
+ expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = EXTERNAL_URL),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ val button = result.providerButton
+ assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_provider))
+ button?.onClick?.invoke()
+ assertThat(openedUrls).containsExactly(EXTERNAL_URL)
+ }
+
+ @Test
+ fun `GIVEN verifying express swap with url WHEN convert THEN go-to-verification button`() {
+ // Act
+ val result = converter.convert(
+ expressSwap(status = ExpressExchangeStatus.Verifying, externalTxUrl = EXTERNAL_URL),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ assertThat(result.providerButton?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
+ }
+
+ @Test
+ fun `GIVEN verifying express onramp with url WHEN convert THEN go-to-verification button opening the url`() {
+ // Act
+ val result = converter.convert(
+ expressOnramp(status = ExpressOnrampStatus.Verifying, externalTxUrl = EXTERNAL_URL),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ val button = result.providerButton
+ assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
+ button?.onClick?.invoke()
+ assertThat(openedUrls).containsExactly(EXTERNAL_URL)
+ }
+
+ @Test
+ fun `GIVEN failed express swap without url WHEN convert THEN no provider button`() {
+ // Act — the provider supplies no link (e.g. DEX), so there is nowhere to send the user.
+ val result = converter.convert(
+ expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = null),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ assertThat(result.providerButton).isNull()
+ }
+
+ @Test
+ fun `GIVEN finished express swap with url WHEN convert THEN no provider button`() {
+ // Act — a settled success needs no provider action even when a link exists.
+ val result = converter.convert(
+ expressSwap(status = ExpressExchangeStatus.Finished, externalTxUrl = EXTERNAL_URL),
+ ) as TxHistoryDetailsUM.TwoAssets
+
+ // Assert
+ assertThat(result.providerButton).isNull()
+ }
+
// endregion
private fun onChain(
@@ -626,6 +773,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
isOutgoing: Boolean = true,
txInfo: OnChainTx? = null,
provider: ExpressProvider? = null,
+ externalTxUrl: String? = null,
): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
@@ -639,8 +787,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
networkId = "bitcoin",
amount = BigDecimal("0.001"),
decimals = 8,
- cryptoCurrency = currency,
+ cryptoCurrency = bitcoin,
),
+ externalTxUrl = externalTxUrl,
),
isOutgoing = isOutgoing,
txInfo = txInfo,
@@ -649,6 +798,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
private fun expressOnramp(
status: ExpressOnrampStatus,
txInfo: OnChainTx? = null,
+ externalTxUrl: String? = null,
): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
@@ -656,6 +806,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
createdAtMillis = TIMESTAMP,
provider = null,
payoutHash = null,
+ externalTxUrl = externalTxUrl,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
@@ -666,7 +817,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
networkId = "bitcoin",
amount = BigDecimal("0.006"),
decimals = 8,
- cryptoCurrency = currency,
+ cryptoCurrency = bitcoin,
),
),
txInfo = txInfo,
@@ -692,5 +843,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
const val VALIDATOR_ADDRESS = "0xvalidator"
+ const val EXTERNAL_URL = "https://provider.example/tx/swap-1"
}
}
\ No newline at end of file