Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-07 22:34:21 +04:00
parent aaacc3d736
commit 54739fff06
8 changed files with 1091 additions and 163 deletions

View file

@ -0,0 +1,90 @@
package com.tangem.common.ui.swap
import com.tangem.domain.models.currency.CryptoCurrency
import java.util.Locale
/**
* Resolves which currency goes first (base) and which goes second (quote) when displaying an
* exchange rate for a swap pair ([REDACTED_TASK_KEY]).
*
* Categories used by the rules:
* - **Stable** a [CryptoCurrency.Token] whose symbol is in [STABLECOIN_RANKS].
* - **Coin** a [CryptoCurrency.Coin] (any native coin: BTC, ETH, SOL, TRX, ...).
* - Anything else (a [CryptoCurrency.Token] outside the stable list) falls into the default
* branch and is treated as a regular token.
*
* Rules:
* - Stable Stable: base = the one ranked higher in [STABLECOIN_RANKS].
* - Coin Stable / Stable Coin: base is the coin.
* - Coin Coin with BTC or ETH: base is the other coin, quote is BTC/ETH.
* - ETH BTC (both directions): base = ETH, quote = BTC.
* - Otherwise (regular CoinCoin, any pair involving a non-stable Token): base = TO, quote = FROM.
*/
internal object SwapRateDirectionResolver {
private val STABLECOIN_RANKS: Map<String, Int> = listOf(
"USDT", "USDC", "USDe", "DAI", "USD1", "PYUSD", "RLUSD", "USDG", "USDf", "USDD",
).withIndex().associate { (rank, symbol) -> symbol.uppercase(Locale.ROOT) to rank }
private const val BTC_SYMBOL = "BTC"
private const val ETH_SYMBOL = "ETH"
fun resolve(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val isFromStable = from.isStable()
val isToStable = to.isStable()
return when {
isFromStable && isToStable -> resolveStableToStable(from, to)
isFromStable -> SwapRateDirection(base = to, quote = from)
isToStable -> SwapRateDirection(base = from, quote = to)
from.isCoin() && to.isCoin() -> resolveCoinToCoin(from, to)
else -> SwapRateDirection(base = to, quote = from)
}
}
private fun resolveStableToStable(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val fromRank = stableRank(from.symbol.uppercaseRoot())
val toRank = stableRank(to.symbol.uppercaseRoot())
return if (fromRank <= toRank) {
SwapRateDirection(base = from, quote = to)
} else {
SwapRateDirection(base = to, quote = from)
}
}
private fun resolveCoinToCoin(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val fromSymbol = from.symbol.uppercaseRoot()
val toSymbol = to.symbol.uppercaseRoot()
val isFromBtcOrEth = fromSymbol.isBtcOrEth()
val isToBtcOrEth = toSymbol.isBtcOrEth()
return when {
isFromBtcOrEth && isToBtcOrEth -> resolveBtcEth(from, to, fromSymbol)
isFromBtcOrEth -> SwapRateDirection(base = to, quote = from)
isToBtcOrEth -> SwapRateDirection(base = from, quote = to)
else -> SwapRateDirection(base = to, quote = from)
}
}
private fun resolveBtcEth(from: CryptoCurrency, to: CryptoCurrency, fromSymbol: String): SwapRateDirection {
return if (fromSymbol == ETH_SYMBOL) {
SwapRateDirection(base = from, quote = to)
} else {
SwapRateDirection(base = to, quote = from)
}
}
private fun stableRank(symbol: String): Int = STABLECOIN_RANKS[symbol] ?: Int.MAX_VALUE
private fun CryptoCurrency.isStable(): Boolean {
return this is CryptoCurrency.Token && STABLECOIN_RANKS.containsKey(symbol.uppercaseRoot())
}
private fun CryptoCurrency.isCoin(): Boolean = this is CryptoCurrency.Coin
private fun String.isBtcOrEth(): Boolean = this == BTC_SYMBOL || this == ETH_SYMBOL
private fun String.uppercaseRoot(): String = uppercase(Locale.ROOT)
}
internal data class SwapRateDirection(val base: CryptoCurrency, val quote: CryptoCurrency)

View file

@ -0,0 +1,88 @@
package com.tangem.common.ui.swap
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.tangem.core.ui.extensions.appendSpace
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.min
/**
* Formats a swap exchange rate as `1 {base} {rate} {quote}`.
*
* The base/quote choice follows the rules in [SwapRateDirectionResolver] ([REDACTED_TASK_KEY]).
*/
object SwapRateFormatter {
private const val MAX_DECIMALS_TO_SHOW = 8
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
fun formatRate(from: CryptoCurrency, to: CryptoCurrency, fromAmount: BigDecimal, toAmount: BigDecimal): String {
val (base, quote, rate) = computeRate(
from = from,
to = to,
fromAmount = fromAmount,
toAmount = toAmount,
)
return buildString {
append(BigDecimal.ONE.format { crypto(symbol = base.symbol, decimals = 0).anyDecimals() })
append(StringsSigns.WHITE_SPACE)
append(StringsSigns.APPROXIMATE)
append(StringsSigns.WHITE_SPACE)
append(rate.format { crypto(quote) })
}
}
fun formatRateAnnotated(
from: CryptoCurrency,
to: CryptoCurrency,
fromAmount: BigDecimal,
toAmount: BigDecimal,
): AnnotatedString {
val (base, quote, rate) = computeRate(
from = from,
to = to,
fromAmount = fromAmount,
toAmount = toAmount,
)
return buildAnnotatedString {
append(BigDecimal.ONE.format { crypto(symbol = base.symbol, decimals = 0).anyDecimals() })
appendSpace()
append(StringsSigns.APPROXIMATE)
appendSpace()
append(rate.format { crypto(quote) })
}
}
private fun computeRate(
from: CryptoCurrency,
to: CryptoCurrency,
fromAmount: BigDecimal,
toAmount: BigDecimal,
): RateComputation {
val direction = SwapRateDirectionResolver.resolve(from, to)
val baseAmount: BigDecimal
val quoteAmount: BigDecimal
if (direction.base == from) {
baseAmount = fromAmount
quoteAmount = toAmount
} else {
baseAmount = toAmount
quoteAmount = fromAmount
}
val rate = if (baseAmount.signum() == 0) {
BigDecimal.ZERO
} else {
val rateDecimals = if (direction.quote.decimals == 0) IF_ZERO_DECIMALS_TO_SHOW else direction.quote.decimals
quoteAmount.divide(baseAmount, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
}
return RateComputation(direction.base, direction.quote, rate)
}
private data class RateComputation(val base: CryptoCurrency, val quote: CryptoCurrency, val rate: BigDecimal)
}

View file

@ -0,0 +1,184 @@
package com.tangem.common.ui.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
internal class SwapRateDirectionResolverTest {
@Test
fun `GIVEN stable usdt and stable usdc WHEN resolve THEN base is usdt`() {
val usdt = stable(symbol = "USDT")
val usdc = stable(symbol = "USDC")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdc))
}
@Test
fun `GIVEN stable dai and stable usdt WHEN resolve THEN base is usdt`() {
val dai = stable(symbol = "DAI")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = dai, to = usdt)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = dai))
}
@Test
fun `GIVEN stable usdd and stable usdc WHEN resolve THEN base is usdc`() {
val usdd = stable(symbol = "USDD")
val usdc = stable(symbol = "USDC")
val result = SwapRateDirectionResolver.resolve(from = usdd, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdc, quote = usdd))
}
@Test
fun `GIVEN coin and stable WHEN resolve THEN base is coin`() {
val sol = coin(symbol = "SOL")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = sol, to = usdt)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdt))
}
@Test
fun `GIVEN stable and coin WHEN resolve THEN base is coin`() {
val usdt = stable(symbol = "USDT")
val sol = coin(symbol = "SOL")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = sol)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdt))
}
@Test
fun `GIVEN coin and btc WHEN resolve THEN base is coin and quote is btc`() {
val sol = coin(symbol = "SOL")
val btc = coin(symbol = "BTC")
val result = SwapRateDirectionResolver.resolve(from = sol, to = btc)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = btc))
}
@Test
fun `GIVEN btc and coin WHEN resolve THEN base is coin and quote is btc`() {
val btc = coin(symbol = "BTC")
val trx = coin(symbol = "TRX")
val result = SwapRateDirectionResolver.resolve(from = btc, to = trx)
assertThat(result).isEqualTo(SwapRateDirection(base = trx, quote = btc))
}
@Test
fun `GIVEN coin and eth WHEN resolve THEN base is coin and quote is eth`() {
val sol = coin(symbol = "SOL")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = sol, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = eth))
}
@Test
fun `GIVEN eth and coin WHEN resolve THEN base is coin and quote is eth`() {
val eth = coin(symbol = "ETH")
val sol = coin(symbol = "SOL")
val result = SwapRateDirectionResolver.resolve(from = eth, to = sol)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = eth))
}
@Test
fun `GIVEN btc and eth WHEN resolve THEN base is eth and quote is btc`() {
val btc = coin(symbol = "BTC")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = btc, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = btc))
}
@Test
fun `GIVEN eth and btc WHEN resolve THEN base is eth and quote is btc`() {
val eth = coin(symbol = "ETH")
val btc = coin(symbol = "BTC")
val result = SwapRateDirectionResolver.resolve(from = eth, to = btc)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = btc))
}
@Test
fun `GIVEN two non-major coins WHEN resolve THEN base is to and quote is from`() {
val sol = coin(symbol = "SOL")
val trx = coin(symbol = "TRX")
val result = SwapRateDirectionResolver.resolve(from = sol, to = trx)
assertThat(result).isEqualTo(SwapRateDirection(base = trx, quote = sol))
}
@Test
fun `GIVEN token symbol matching priority list but not Token type WHEN resolve THEN treated as coin`() {
// Edge: a CryptoCurrency.Coin whose symbol coincidentally equals a stable symbol must NOT
// be treated as stable — the type check is type-aware now.
val usdtLikeCoin = coin(symbol = "USDT")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = usdtLikeCoin, to = usdt)
// usdt is stable, usdtLikeCoin is a Coin → Coin↔Stable rule, base = coin
assertThat(result).isEqualTo(SwapRateDirection(base = usdtLikeCoin, quote = usdt))
}
@Test
fun `GIVEN non-stable token and coin WHEN resolve THEN base is to and quote is from`() {
// Non-stable Token (e.g., LINK) is neither Stable nor Coin → falls into default branch.
val link = token(symbol = "LINK")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = link, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = link))
}
@Test
fun `GIVEN two non-stable tokens WHEN resolve THEN base is to and quote is from`() {
val link = token(symbol = "LINK")
val aave = token(symbol = "AAVE")
val result = SwapRateDirectionResolver.resolve(from = link, to = aave)
assertThat(result).isEqualTo(SwapRateDirection(base = aave, quote = link))
}
@Test
fun `GIVEN lowercase usdt and lowercase usdc WHEN resolve THEN base is usdt`() {
val usdt = stable(symbol = "usdt")
val usdc = stable(symbol = "usdc")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdc))
}
private fun coin(symbol: String): CryptoCurrency = mockk<CryptoCurrency.Coin> {
every { this@mockk.symbol } returns symbol
}
private fun token(symbol: String): CryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.symbol } returns symbol
}
private fun stable(symbol: String): CryptoCurrency = token(symbol)
}

View file

@ -0,0 +1,161 @@
package com.tangem.common.ui.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.StringsSigns
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Locale
internal class SwapRateFormatterTest {
private var originalLocale: Locale = Locale.getDefault()
@BeforeEach
fun setUp() {
originalLocale = Locale.getDefault()
Locale.setDefault(Locale.US)
}
@AfterEach
fun tearDown() {
Locale.setDefault(originalLocale)
}
@Test
fun `GIVEN coin to stable swap WHEN formatRate THEN base is coin`() {
val eth = coin(symbol = "ETH", decimals = 18)
val usdt = stable(symbol = "USDT", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = eth,
to = usdt,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("3000"),
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("3,000")
}
@Test
fun `GIVEN stable to coin swap WHEN formatRate THEN base is coin`() {
val usdt = stable(symbol = "USDT", decimals = 6)
val eth = coin(symbol = "ETH", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = usdt,
to = eth,
fromAmount = BigDecimal("3000"),
toAmount = BigDecimal.ONE,
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("3,000")
}
@Test
fun `GIVEN btc to other coin swap WHEN formatRate THEN base is other coin`() {
val btc = coin(symbol = "BTC", decimals = 8)
val sol = coin(symbol = "SOL", decimals = 8)
val result = SwapRateFormatter.formatRate(
from = btc,
to = sol,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("20"),
)
result.assertOrder(base = "SOL", quote = "BTC")
assertThat(result).contains("0.05")
}
@Test
fun `GIVEN btc and eth swap WHEN formatRate THEN base is eth`() {
val btc = coin(symbol = "BTC", decimals = 8)
val eth = coin(symbol = "ETH", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = btc,
to = eth,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("18"),
)
result.assertOrder(base = "ETH", quote = "BTC")
assertThat(result).contains("0.05555")
}
@Test
fun `GIVEN two stables swap WHEN formatRate THEN base is higher ranked`() {
val usdc = stable(symbol = "USDC", decimals = 6)
val dai = stable(symbol = "DAI", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = dai,
to = usdc,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("0.999"),
)
result.assertOrder(base = "USDC", quote = "DAI")
assertThat(result).contains("1.001")
}
@Test
fun `GIVEN two non-major coins swap WHEN formatRate THEN base is to currency`() {
val sol = coin(symbol = "SOL", decimals = 8)
val trx = coin(symbol = "TRX", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = sol,
to = trx,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("100"),
)
result.assertOrder(base = "TRX", quote = "SOL")
assertThat(result).contains("0.01")
}
@Test
fun `GIVEN zero from amount WHEN formatRate THEN rate is zero`() {
val eth = coin(symbol = "ETH", decimals = 18)
val usdt = stable(symbol = "USDT", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = eth,
to = usdt,
fromAmount = BigDecimal.ZERO,
toAmount = BigDecimal.ZERO,
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("0.00")
}
private fun String.assertOrder(base: String, quote: String) {
val baseIndex = indexOf(base)
val quoteIndex = lastIndexOf(quote)
assertThat(baseIndex).isAtLeast(0)
assertThat(quoteIndex).isGreaterThan(baseIndex)
assertThat(this).contains(StringsSigns.APPROXIMATE)
val approximateIndex = indexOf(StringsSigns.APPROXIMATE)
assertThat(approximateIndex).isGreaterThan(baseIndex)
assertThat(quoteIndex).isGreaterThan(approximateIndex)
}
private fun coin(symbol: String, decimals: Int): CryptoCurrency = mockk<CryptoCurrency.Coin> {
every { this@mockk.symbol } returns symbol
every { this@mockk.decimals } returns decimals
}
private fun stable(symbol: String, decimals: Int): CryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.symbol } returns symbol
every { this@mockk.decimals } returns decimals
}
}