Updated on 2026-08-14
This commit is contained in:
parent
aaacc3d736
commit
54739fff06
8 changed files with 1091 additions and 163 deletions
|
|
@ -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 Coin↔Coin, 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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,16 @@
|
|||
package com.tangem.features.swap.v2.impl.amount.model.converter
|
||||
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.tangem.common.ui.swap.SwapRateFormatter
|
||||
import com.tangem.core.ui.extensions.annotatedReference
|
||||
import com.tangem.core.ui.extensions.appendSpace
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
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.express.models.ExpressProvider
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.SwapDirection
|
||||
import com.tangem.domain.swap.models.SwapQuoteModel
|
||||
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.calculateRate
|
||||
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
|
||||
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -29,18 +25,12 @@ internal class SwapQuoteUMConverter(
|
|||
override fun convert(value: Data): SwapQuoteUM {
|
||||
val (quote, provider) = value
|
||||
|
||||
val rate = calculateRate(
|
||||
val rateString = SwapRateFormatter.formatRateAnnotated(
|
||||
from = primaryCurrency,
|
||||
to = secondaryCurrency,
|
||||
fromAmount = fromAmount,
|
||||
toAmount = quote.toTokenAmount,
|
||||
toAmountDecimals = secondaryCurrency.decimals,
|
||||
)
|
||||
val rateString = buildAnnotatedString {
|
||||
append(BigDecimal.ONE.format { crypto(symbol = primaryCurrency.symbol, decimals = 0).anyDecimals() })
|
||||
appendSpace()
|
||||
append(StringsSigns.APPROXIMATE)
|
||||
appendSpace()
|
||||
append(rate.format { crypto(secondaryCurrency) })
|
||||
}
|
||||
|
||||
val fromAmountValue = stringReference(
|
||||
quote.fromTokenAmount?.format { crypto(primaryCurrency) }.orEmpty(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.common.ui.swap.SwapRateFormatter
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
||||
/**
|
||||
* Builds [ProviderState.Content] for the swap provider list / row.
|
||||
*
|
||||
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
|
||||
*/
|
||||
internal object SwapProviderStateBuilder {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Provider row on the main swap screen — shows the exchange rate `1 base ≈ rate quote`
|
||||
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun buildContentClickable(
|
||||
provider: SwapProvider,
|
||||
fromTokenInfo: TokenSwapInfo,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
val rateString = SwapRateFormatter.formatRate(
|
||||
from = fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = fromTokenInfo.tokenAmount.value,
|
||||
toAmount = toTokenInfo.tokenAmount.value,
|
||||
)
|
||||
return provider.toContent(
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider row in the provider-picker bottom sheet. Subtitle shows the formatted *to* amount
|
||||
* (not a rate) and the row carries a percentage delta vs. the best rate.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun buildContentSelectable(
|
||||
provider: SwapProvider,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = buildSelectableSubtitle(toTokenInfo),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
),
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = pricesLowerBest[provider.providerId]
|
||||
?.let(PercentDifference::Value)
|
||||
?: PercentDifference.Value(0f),
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider row for an unavailable / errored provider — subtitle is the error/alert text
|
||||
* resolved by the caller.
|
||||
*/
|
||||
fun buildAvailableFrom(
|
||||
provider: SwapProvider,
|
||||
alertText: TextReference,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = alertText,
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
),
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtitle (formatted *to* amount) used both for picker rows and when refreshing
|
||||
* the provider-picker bottom sheet. Single source of truth so both paths stay in sync.
|
||||
*/
|
||||
fun buildSelectableSubtitle(toTokenInfo: TokenSwapInfo): TextReference {
|
||||
val toAmount = toTokenInfo.tokenAmount.value.format {
|
||||
crypto(toTokenInfo.swapCurrencyStatus.currency)
|
||||
}
|
||||
return stringReference(toAmount)
|
||||
}
|
||||
|
||||
private fun resolveBadge(
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
permissionState: PermissionDataState? = null,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
): ProviderState.AdditionalBadge {
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() ->
|
||||
ProviderState.AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired ->
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended ->
|
||||
ProviderState.AdditionalBadge.Recommended
|
||||
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
|
||||
ProviderState.AdditionalBadge.BestTrade
|
||||
else ->
|
||||
ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
private fun SwapProvider.toContent(
|
||||
subtitle: TextReference,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
percentLowerThenBest: PercentDifference,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return ProviderState.Content(
|
||||
id = providerId,
|
||||
name = name,
|
||||
iconUrl = imageLarge,
|
||||
type = type.providerName,
|
||||
subtitle = subtitle,
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = percentLowerThenBest,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.RateType
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -46,8 +47,6 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* State builder creates a specific states for SwapScreen
|
||||
|
|
@ -549,15 +548,16 @@ internal class StateBuilder(
|
|||
onClick = actions.onSwapClick,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = swapProvider.convertToContentClickableProviderState(
|
||||
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
|
||||
providerState = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = swapProvider,
|
||||
fromTokenInfo = quoteModel.fromTokenInfo,
|
||||
toTokenInfo = quoteModel.toTokenInfo,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = quoteModel.permissionState,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
),
|
||||
priceImpact = priceImpact,
|
||||
tosState = createTosState(swapProvider),
|
||||
|
|
@ -686,27 +686,27 @@ internal class StateBuilder(
|
|||
): ProviderState {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeTooSmallAmountError -> {
|
||||
swapProvider.convertToAvailableFromProviderState(
|
||||
swapProvider = swapProvider,
|
||||
SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = swapProvider,
|
||||
alertText = resourceReference(
|
||||
R.string.express_provider_min_amount,
|
||||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
onProviderClick = onProviderClick,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
is ExpressDataError.ExchangeTooBigAmountError -> {
|
||||
swapProvider.convertToAvailableFromProviderState(
|
||||
swapProvider = swapProvider,
|
||||
SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = swapProvider,
|
||||
alertText = resourceReference(
|
||||
R.string.express_provider_max_amount,
|
||||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
onProviderClick = onProviderClick,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
|
|
@ -1053,10 +1053,8 @@ internal class StateBuilder(
|
|||
providers = providers.map { providerState ->
|
||||
val tokenInfo = tokenSwapInfoForProviders[providerState.id]
|
||||
if (providerState is ProviderState.Content && tokenInfo != null) {
|
||||
val rateString = tokenInfo.tokenAmount
|
||||
.getFormattedCryptoAmount(tokenInfo.swapCurrencyStatus.currency)
|
||||
providerState.copy(
|
||||
subtitle = stringReference(rateString),
|
||||
subtitle = SwapProviderStateBuilder.buildSelectableSubtitle(tokenInfo),
|
||||
percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent ->
|
||||
PercentDifference.Value(percent)
|
||||
} ?: PercentDifference.Value(0f),
|
||||
|
|
@ -1135,12 +1133,14 @@ internal class StateBuilder(
|
|||
return when (val state = this.value) {
|
||||
is SwapState.EmptyAmountState -> null
|
||||
is SwapState.QuotesLoadedState -> {
|
||||
provider.convertToContentSelectableProviderState(
|
||||
state = state,
|
||||
onProviderClick = onProviderSelect,
|
||||
SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = state.toTokenInfo,
|
||||
permissionState = state.permissionState,
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
onProviderClick = onProviderSelect,
|
||||
)
|
||||
}
|
||||
is SwapState.SwapError -> getProviderStateForError(
|
||||
|
|
@ -1154,113 +1154,6 @@ internal class StateBuilder(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun SwapProvider.convertToContentClickableProviderState(
|
||||
isBestRate: Boolean,
|
||||
fromTokenInfo: TokenSwapInfo,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
onProviderClick: (String) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
permissionState: PermissionDataState,
|
||||
): ProviderState {
|
||||
val rate = toTokenInfo.tokenAmount.value.calculateRate(
|
||||
fromTokenInfo.tokenAmount.value,
|
||||
toTokenInfo.swapCurrencyStatus.currency.decimals,
|
||||
)
|
||||
val fromCurrencySymbol = fromTokenInfo.swapCurrencyStatus.currency.symbol
|
||||
val rateString = buildString {
|
||||
append(BigDecimal.ONE.format { crypto(symbol = fromCurrencySymbol, decimals = 0).anyDecimals() })
|
||||
append(" ≈ ")
|
||||
append(rate.format { crypto(toTokenInfo.swapCurrencyStatus.currency) })
|
||||
}
|
||||
|
||||
val additionalBadge = when {
|
||||
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired ->
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
isRecommended -> ProviderState.AdditionalBadge.Recommended
|
||||
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> ProviderState.AdditionalBadge.BestTrade
|
||||
else -> ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
return ProviderState.Content(
|
||||
id = this.providerId,
|
||||
name = this.name,
|
||||
iconUrl = this.imageLarge,
|
||||
type = this.type.providerName,
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.convertToContentSelectableProviderState(
|
||||
state: SwapState.QuotesLoadedState,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
onProviderClick: (String) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
): ProviderState {
|
||||
val toTokenInfo = state.toTokenInfo
|
||||
val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.swapCurrencyStatus.currency)
|
||||
|
||||
val additionalBadge = when {
|
||||
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
|
||||
state.permissionState is PermissionDataState.PermissionRequired -> {
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
}
|
||||
isRecommended -> ProviderState.AdditionalBadge.Recommended
|
||||
else -> ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
return ProviderState.Content(
|
||||
id = this.providerId,
|
||||
name = this.name,
|
||||
iconUrl = this.imageLarge,
|
||||
type = this.type.providerName,
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent ->
|
||||
PercentDifference.Value(percent)
|
||||
} ?: PercentDifference.Value(0f),
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.convertToAvailableFromProviderState(
|
||||
swapProvider: SwapProvider,
|
||||
alertText: TextReference,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
onProviderClick: (String) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
): ProviderState {
|
||||
val additionalBadge = when {
|
||||
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
|
||||
swapProvider.isRecommended -> ProviderState.AdditionalBadge.Recommended
|
||||
else -> ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
return ProviderState.Content(
|
||||
id = this.providerId,
|
||||
name = this.name,
|
||||
iconUrl = this.imageLarge,
|
||||
type = this.type.providerName,
|
||||
selectionType = selectionType,
|
||||
subtitle = alertText,
|
||||
additionalBadge = additionalBadge,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String {
|
||||
val amount = this?.value?.amount ?: return DASH_SIGN
|
||||
val symbol = if (isNeedSymbol) currency.symbol else ""
|
||||
|
|
@ -1284,19 +1177,10 @@ internal class StateBuilder(
|
|||
return value.format { crypto(token) }
|
||||
}
|
||||
|
||||
private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal {
|
||||
val rateDecimals = if (decimals == 0) IF_ZERO_DECIMALS_TO_SHOW else decimals
|
||||
return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
private fun String.appendApproximateSign(): String {
|
||||
return "$TILDE_SIGN $this"
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestrictedProvider(): Boolean {
|
||||
return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId)
|
||||
}
|
||||
|
||||
private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM {
|
||||
val (prefix, placeholder) = if (isFromCard) {
|
||||
R.string.swapping_from_account_title to R.string.swapping_from_title_v2
|
||||
|
|
@ -1320,17 +1204,4 @@ internal class StateBuilder(
|
|||
is Account.Payment -> AccountIconUM.Payment
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val MAX_DECIMALS_TO_SHOW = 8
|
||||
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
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 SwapProviderStateBuilderTest {
|
||||
|
||||
private var originalLocale: Locale = Locale.getDefault()
|
||||
|
||||
private val onProviderClick: (String) -> Unit = {}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
originalLocale = Locale.getDefault()
|
||||
Locale.setDefault(Locale.US)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
Locale.setDefault(originalLocale)
|
||||
}
|
||||
|
||||
// region buildContentClickable
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = from,
|
||||
toTokenInfo = to,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
val subtitle = result.subtitle as TextReference.Str
|
||||
assertThat(subtitle.value).contains("ETH")
|
||||
assertThat(subtitle.value).contains("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "changelly", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x")
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = false,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.id).isEqualTo("1inch")
|
||||
assertThat(result.name).isEqualTo("1inch")
|
||||
assertThat(result.iconUrl).isEqualTo("https://x")
|
||||
assertThat(result.type).isEqualTo("DEX")
|
||||
assertThat(result.selectionType).isEqualTo(ProviderState.SelectionType.CLICK)
|
||||
assertThat(result.namePrefix).isEqualTo(ProviderState.PrefixType.NONE)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region buildContentSelectable
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = mapOf("1inch" to 0.5f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Value(0.5f))
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
val subtitle = result.subtitle as TextReference.Str
|
||||
assertThat(subtitle.value).contains("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Value(0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate badge inputs WHEN buildContentSelectable THEN BestTrade badge is never set`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region buildAvailableFrom
|
||||
|
||||
@Test
|
||||
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val alert: TextReference = stringReference("min amount 0.01 ETH")
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = alert,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.subtitle).isEqualTo(alert)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "okx-on-chain", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region buildSelectableSubtitle
|
||||
|
||||
@Test
|
||||
fun `GIVEN to token info WHEN buildSelectableSubtitle THEN string contains symbol`() {
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildSelectableSubtitle(info)
|
||||
|
||||
assertThat(result).isInstanceOf(TextReference.Str::class.java)
|
||||
val subtitle = result as TextReference.Str
|
||||
assertThat(subtitle.value).contains("USDT")
|
||||
assertThat(subtitle.value).contains("100")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun provider(
|
||||
id: String,
|
||||
isRecommended: Boolean,
|
||||
name: String = "Provider",
|
||||
iconUrl: String = "https://icon",
|
||||
): SwapProvider = mockk {
|
||||
every { providerId } returns id
|
||||
every { this@mockk.name } returns name
|
||||
every { imageLarge } returns iconUrl
|
||||
every { type } returns ExchangeProviderType.DEX
|
||||
every { this@mockk.isRecommended } returns isRecommended
|
||||
}
|
||||
|
||||
private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {
|
||||
val currency = mockk<CryptoCurrency.Coin> {
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.decimals } returns decimals
|
||||
}
|
||||
val swapStatus = mockk<SwapCurrencyStatus> {
|
||||
every { this@mockk.currency } returns currency
|
||||
}
|
||||
return TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(value = amount, decimals = decimals),
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
swapCurrencyStatus = swapStatus,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue