Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-13 20:52:31 +05:00
parent a821732662
commit 30431a3dbb
20 changed files with 540 additions and 34 deletions

View file

@ -3,6 +3,8 @@ package com.tangem.tap.di.domain
import com.tangem.domain.blockaid.BlockAidGasEstimate import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
@ -132,4 +134,18 @@ internal object YieldSupplyDomainModule {
yieldSupplyRepository = yieldSupplyRepository, yieldSupplyRepository = yieldSupplyRepository,
) )
} }
@Provides
@Singleton
fun provideYieldSupplyMinAmountUseCase(
feeRepository: FeeRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
): YieldSupplyMinAmountUseCase {
return YieldSupplyMinAmountUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
)
}
} }

View file

@ -21,6 +21,8 @@ dependencies {
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
implementation(projects.domain.blockaid.models) implementation(projects.domain.blockaid.models)
implementation(projects.domain.blockaid) implementation(projects.domain.blockaid)
implementation(projects.domain.quotes)
implementation(projects.domain.tokens)
/** Tandem SDK */ /** Tandem SDK */
implementation(tangemDeps.blockchain) implementation(tangemDeps.blockchain)

View file

@ -0,0 +1,88 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.FeeRepository
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
class YieldSupplyMinAmountUseCase(
private val feeRepository: FeeRepository,
private val quotesRepository: QuotesRepository,
private val currenciesRepository: CurrenciesRepository,
) {
suspend operator fun invoke(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Either<Throwable, BigDecimal> = catch {
val feeWithoutGas = feeRepository.getEthereumFeeWithoutGas(userWallet, cryptoCurrencyStatus.currency)
val fiatRate = cryptoCurrencyStatus.value.fiatRate ?: error("Fiat rate is missing")
require(fiatRate > BigDecimal.ZERO) { "Fiat rate for token must be > 0" }
val nativeCryptoCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = cryptoCurrencyStatus.currency.network.id,
derivationPath = cryptoCurrencyStatus.currency.network.derivationPath,
)
val quotes =
quotesRepository.getMultiQuoteSyncOrNull(setOfNotNull(nativeCryptoCurrency.id.rawCurrencyId))
?: error("Quotes for native coin are unavailable")
val quotesStatus = quotes.firstOrNull() ?: error("Empty quotes list for native coin")
val nativeFiatRate = (quotesStatus.value as? QuoteStatus.Data)?.fiatRate
?: error("Native fiat rate is missing")
require(nativeFiatRate > BigDecimal.ZERO) { "Native fiat rate must be > 0" }
val nativeGas = feeWithoutGas.fixFee(nativeCryptoCurrency, ETHEREUM_CONSTANT_GAS_LIMIT)
val rateRatio = nativeFiatRate.divide(
fiatRate,
cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
val tokenValue = rateRatio.multiply(nativeGas.amount.value)
val feeBuffered = tokenValue.multiply(FEE_BUFFER_MULTIPLIER)
feeBuffered
.divide(MAX_FEE_PERCENT, cryptoCurrencyStatus.currency.decimals, RoundingMode.HALF_UP)
.stripTrailingZeros()
}
private fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger) = when (this) {
is Fee.Ethereum.Legacy -> copy(
gasLimit = gasLimit,
amount = amount.copy(
value = gasPrice.multiply(gasLimit)
.toBigDecimal().movePointLeft(cryptoCurrency.decimals),
),
)
is Fee.Ethereum.EIP1559 -> copy(
gasLimit = gasLimit,
amount = amount.copy(
value = maxFeePerGas.multiply(gasLimit)
.toBigDecimal().movePointLeft(cryptoCurrency.decimals),
),
)
else -> this
}
private companion object {
val FEE_BUFFER_MULTIPLIER: BigDecimal = BigDecimal("1.25")
val MAX_FEE_PERCENT: BigDecimal = BigDecimal("0.04")
val ETHEREUM_CONSTANT_GAS_LIMIT = 350_000.toBigInteger()
}
}

View file

@ -0,0 +1,247 @@
package com.tangem.domain.yield.supply
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
class YieldSupplyMinAmountUseCaseTest {
private val feeRepository: FeeRepository = mockk(relaxed = true)
private val quotesRepository: QuotesRepository = mockk(relaxed = true)
private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
private val useCase = YieldSupplyMinAmountUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
)
@Test
fun `GIVEN valid inputs WHEN invoke THEN return expected min amount`() = runTest {
val network = createNetwork()
val nativeCoin = createNativeCoin(network)
val token = createToken(network)
val tokenFiatRate = BigDecimal("1.00")
val tokenStatus = CryptoCurrencyStatus(
currency = token,
value = CryptoCurrencyStatus.Loaded(
amount = BigDecimal.ZERO,
fiatAmount = BigDecimal.ZERO,
fiatRate = tokenFiatRate,
priceChange = BigDecimal.ZERO,
yieldBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
),
sources = CryptoCurrencyStatus.Sources(),
),
)
val userWallet = createUserWallet()
val maxFeePerGas = BigInteger("158320679232")
val fee = createEip1559Fee(maxFeePerGas)
coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee
coEvery {
currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = token.network.id,
derivationPath = token.network.derivationPath,
)
} returns nativeCoin
val nativeFiatRate = BigDecimal("0.20353756561552608")
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns setOf(
QuoteStatus(
rawCurrencyId = CryptoCurrency.RawID("polygon-ecosystem-token"),
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = nativeFiatRate,
priceChange = BigDecimal("0.09000000000000007"),
),
),
)
val expected = expectedMinAmount(maxFeePerGas, nativeFiatRate, tokenFiatRate, token.decimals)
val result = useCase(userWallet, tokenStatus).getOrNull()
Truth.assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN missing token fiat rate WHEN invoke THEN return left with error`() = runTest {
val network = createNetwork()
val token = createToken(network)
val tokenStatus = CryptoCurrencyStatus(
currency = token,
value = CryptoCurrencyStatus.Custom(
amount = BigDecimal.ZERO,
fiatAmount = null,
fiatRate = null,
priceChange = null,
yieldBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
),
sources = CryptoCurrencyStatus.Sources(),
),
)
val userWallet = createUserWallet()
val result = useCase(userWallet, tokenStatus)
Truth.assertThat(result.isLeft()).isTrue()
Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Fiat rate is missing")
}
@Test
fun `GIVEN quotes unavailable WHEN invoke THEN return left with error`() = runTest {
val network = createNetwork()
val nativeCoin = createNativeCoin(network)
val token = createToken(network)
val tokenStatus = CryptoCurrencyStatus(
currency = token,
value = CryptoCurrencyStatus.Custom(
amount = BigDecimal.ZERO,
fiatAmount = BigDecimal.ZERO,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
yieldBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
),
sources = CryptoCurrencyStatus.Sources(),
),
)
val userWallet = createUserWallet()
val maxFeePerGas = BigInteger("158320679232")
val fee = createEip1559Fee(maxFeePerGas)
coEvery { feeRepository.getEthereumFeeWithoutGas(userWallet, token) } returns fee
coEvery {
currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = token.network.id,
derivationPath = token.network.derivationPath,
)
} returns nativeCoin
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns null
val result = useCase(userWallet, tokenStatus)
Truth.assertThat(result.isLeft()).isTrue()
Truth.assertThat(result.leftOrNull()?.message).isEqualTo("Quotes for native coin are unavailable")
}
private fun createNetwork(): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(Network.RawID("polygon"), derivationPath),
backendId = "polygon",
name = "Polygon",
currencySymbol = "MATIC",
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.ENS,
)
}
private fun createNativeCoin(network: Network): CryptoCurrency.Coin {
val nativeCoinId = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(network.rawId),
suffix = CryptoCurrency.ID.Suffix.RawID("polygon-ecosystem-token"),
)
return CryptoCurrency.Coin(
id = nativeCoinId,
network = network,
name = "Polygon",
symbol = "MATIC",
decimals = 18,
iconUrl = null,
isCustom = false,
)
}
private fun createToken(network: Network): CryptoCurrency.Token {
val tokenId = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(network.rawId),
suffix = CryptoCurrency.ID.Suffix.RawID("test-token", "0xContract"),
)
return CryptoCurrency.Token(
id = tokenId,
network = network,
name = "Test Token",
symbol = "TT",
decimals = 18,
iconUrl = null,
isCustom = false,
contractAddress = "0xContract",
)
}
private fun createUserWallet(): UserWallet {
val wallet = mockk<UserWallet>(relaxed = true)
every { wallet.walletId } returns UserWalletId("001122")
return wallet
}
private fun createEip1559Fee(maxFeePerGas: BigInteger): Fee.Ethereum.EIP1559 {
return Fee.Ethereum.EIP1559(
amount = Amount(value = BigDecimal.ZERO, blockchain = Blockchain.Ethereum),
gasLimit = BigInteger.ZERO,
maxFeePerGas = maxFeePerGas,
priorityFee = BigInteger.ZERO,
)
}
private fun expectedMinAmount(
maxFeePerGas: BigInteger,
nativeFiatRate: BigDecimal,
tokenFiatRate: BigDecimal,
decimals: Int,
): BigDecimal {
val gasLimit = BigInteger("350000")
val nativeGas = maxFeePerGas.multiply(gasLimit).toBigDecimal().movePointLeft(decimals)
val rateRatio = nativeFiatRate.divide(tokenFiatRate, decimals, RoundingMode.HALF_UP)
val tokenValue = rateRatio.multiply(nativeGas)
val feeBuffered = tokenValue.multiply(BigDecimal("1.25"))
return feeBuffered
.divide(BigDecimal("0.04"), decimals, RoundingMode.HALF_UP)
.stripTrailingZeros()
}
}

View file

@ -16,6 +16,7 @@ internal sealed class YieldSupplyFeeUM {
val feeValue: TextReference, val feeValue: TextReference,
val currentNetworkFeeValue: TextReference, val currentNetworkFeeValue: TextReference,
val maxNetworkFeeValue: TextReference, val maxNetworkFeeValue: TextReference,
val minAmountFeeValue: TextReference,
) : YieldSupplyFeeUM() ) : YieldSupplyFeeUM()
} }

View file

@ -0,0 +1,30 @@
package com.tangem.features.yield.supply.impl.common.formatter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
internal class YieldSupplyMinAmountFormatter(
private val feeCryptoCurrency: CryptoCurrency,
private val appCurrency: AppCurrency,
) {
operator fun invoke(feeValue: BigDecimal, fiatRate: BigDecimal?): TextReference {
val cryptoFee = feeValue.format { crypto(feeCryptoCurrency) }
val fiatFeeValue = fiatRate?.let(feeValue::multiply)
val fiatFee = fiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) }
return combinedReference(
stringReference(cryptoFee),
stringReference(" ${StringsSigns.DOT} "),
stringReference(fiatFee),
)
}
}

View file

@ -141,6 +141,7 @@ private class YieldSupplyActionContentPreviewProvider : PreviewParameterProvider
feeValue = stringReference("0.00020 ETH • \$0.99"), feeValue = stringReference("0.00020 ETH • \$0.99"),
currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"), currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"),
maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"), maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"),
minAmountFeeValue = stringReference("50 USDT • \$50"),
), ),
isPrimaryButtonEnabled = false, isPrimaryButtonEnabled = false,
isTransactionSending = false, isTransactionSending = false,

View file

@ -203,7 +203,7 @@ internal class YieldSupplyModel @Inject constructor(
resourceReference( resourceReference(
R.string.yield_module_token_details_earn_notification_apy, R.string.yield_module_token_details_earn_notification_apy,
), ),
stringReference(tokenStatus.apy.toString() + "%"), stringReference(" ${tokenStatus.apy}%"),
), ),
onClick = ::onActiveClick, onClick = ::onActiveClick,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,

View file

@ -42,10 +42,14 @@ internal class YieldSupplyPromoModel @Inject constructor(
} }
override fun onHowItWorksClick() { override fun onHowItWorksClick() {
urlOpener.openUrl("https://tangem.com/") // TODO replace with real link urlOpener.openUrl(HOW_IT_WORKS_URL)
} }
override fun onStartEarningClick() { override fun onStartEarningClick() {
bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action)
} }
companion object {
private const val HOW_IT_WORKS_URL = "https://tangem.com/en/blog/post/savings-account "
}
} }

View file

@ -11,4 +11,5 @@ internal data class YieldSupplyActiveContentUM(
val subtitleLink: TextReference, val subtitleLink: TextReference,
val notificationUM: NotificationUM?, val notificationUM: NotificationUM?,
val apy: TextReference? = null, val apy: TextReference? = null,
val minAmount: TextReference?,
) )

View file

@ -1,5 +1,6 @@
package com.tangem.features.yield.supply.impl.subcomponents.active.model package com.tangem.features.yield.supply.impl.subcomponents.active.model
import arrow.core.getOrElse
import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
@ -11,12 +12,17 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.common.formatter.YieldSupplyMinAmountFormatter
import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent
import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -29,12 +35,15 @@ internal class YieldSupplyActiveModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val yieldSupplyGetProtocolBalanceUseCase: YieldSupplyGetProtocolBalanceUseCase, private val yieldSupplyGetProtocolBalanceUseCase: YieldSupplyGetProtocolBalanceUseCase,
private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() { ) : Model() {
private val params: YieldSupplyActiveComponent.Params = paramsContainer.require() private val params: YieldSupplyActiveComponent.Params = paramsContainer.require()
private val cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow private val cryptoCurrencyStatusFlow = params.cryptoCurrencyStatusFlow
private val cryptoCurrency = cryptoCurrencyStatusFlow.value.currency private val cryptoCurrency = cryptoCurrencyStatusFlow.value.currency
private var appCurrency = AppCurrency.Default
val uiState: StateFlow<YieldSupplyActiveContentUM> val uiState: StateFlow<YieldSupplyActiveContentUM>
field = MutableStateFlow( field = MutableStateFlow(
@ -48,6 +57,7 @@ internal class YieldSupplyActiveModel @Inject constructor(
), ),
subtitleLink = resourceReference(R.string.common_read_more), subtitleLink = resourceReference(R.string.common_read_more),
notificationUM = null, notificationUM = null,
minAmount = null,
), ),
) )
@ -55,6 +65,7 @@ internal class YieldSupplyActiveModel @Inject constructor(
subscribeOnCurrencyUpdates() subscribeOnCurrencyUpdates()
modelScope.launch(dispatchers.default) { modelScope.launch(dispatchers.default) {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
val protocolBalance = yieldSupplyGetProtocolBalanceUseCase( val protocolBalance = yieldSupplyGetProtocolBalanceUseCase(
userWalletId = params.userWallet.walletId, userWalletId = params.userWallet.walletId,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
@ -93,6 +104,7 @@ internal class YieldSupplyActiveModel @Inject constructor(
} }
loadApy() loadApy()
loadMinAmount()
uiState.update { uiState.update {
it.copy( it.copy(
@ -126,6 +138,27 @@ internal class YieldSupplyActiveModel @Inject constructor(
} }
} }
private fun loadMinAmount() {
modelScope.launch(dispatchers.default) {
yieldSupplyMinAmountUseCase(
params.userWallet,
cryptoCurrencyStatusFlow.value,
).onRight { minAmount ->
val minAmountTextReference = YieldSupplyMinAmountFormatter(
cryptoCurrencyStatusFlow.value.currency,
appCurrency,
).invoke(minAmount, cryptoCurrencyStatusFlow.value.value.fiatRate)
uiState.update {
it.copy(minAmount = minAmountTextReference)
}
}.onLeft {
uiState.update {
it.copy(minAmount = TextReference.Str(DASH_SIGN))
}
}
}
}
private companion object { private companion object {
const val AAVEV3_PREFIX = "a" const val AAVEV3_PREFIX = "a"
} }

View file

@ -59,7 +59,6 @@ internal fun YieldSupplyActiveContent(
CurrentApy(state.apy) CurrentApy(state.apy)
chartComponent.Content(Modifier.padding(bottom = 12.dp)) chartComponent.Content(Modifier.padding(bottom = 12.dp))
} }
YieldSupplyActiveMyFunds(state = state, isBalanceHidden = isBalanceHidden)
AnimatedVisibility(state.notificationUM != null) { AnimatedVisibility(state.notificationUM != null) {
val wrappedNotification = remember(this) { requireNotNull(state.notificationUM) } val wrappedNotification = remember(this) { requireNotNull(state.notificationUM) }
@ -69,6 +68,8 @@ internal fun YieldSupplyActiveContent(
containerColor = TangemTheme.colors.background.action, containerColor = TangemTheme.colors.background.action,
) )
} }
YieldSupplyActiveMyFunds(state = state, isBalanceHidden = isBalanceHidden)
} }
} }
@ -163,6 +164,15 @@ private fun YieldSupplyActiveMyFunds(state: YieldSupplyActiveContentUM, isBalanc
info = state.availableBalance, info = state.availableBalance,
isBalanceHidden = isBalanceHidden, isBalanceHidden = isBalanceHidden,
) )
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors.stroke.primary,
)
InfoRow(
title = resourceReference(R.string.yield_module_fee_policy_sheet_min_amount_title),
info = state.minAmount,
isBalanceHidden = false,
)
} }
} }
@ -251,6 +261,7 @@ private class YieldSupplyActiveBottomSheetPreviewProvider : PreviewParameterProv
subtitleLink = resourceReference(R.string.common_read_more), subtitleLink = resourceReference(R.string.common_read_more),
notificationUM = NotificationUM.Error.InvalidAmount, notificationUM = NotificationUM.Error.InvalidAmount,
apy = stringReference("5,14%"), apy = stringReference("5,14%"),
minAmount = stringReference("50 USDT"),
), ),
) )
} }

View file

@ -1,6 +1,8 @@
package com.tangem.features.yield.supply.impl.subcomponents.approve.model package com.tangem.features.yield.supply.impl.subcomponents.approve.model
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
@ -194,40 +196,47 @@ internal class YieldSupplyApproveModel @Inject constructor(
} }
}, },
ifRight = { fee -> ifRight = { fee ->
val feeCryptoValue = fee.normal.amount.value applyFee(fee, approvalTransitionData)
},
)
}
val feeFiatValue = feeCryptoCurrencyStatus.value.fiatRate?.let { rate -> private suspend fun applyFee(transactionFee: TransactionFee, approvalTransitionData: TransactionData.Uncompiled) {
feeCryptoValue?.multiply(rate) val feeCryptoValue = transactionFee.normal.amount.value
}
val cryptoFee = feeCryptoValue.format { crypto(feeCryptoCurrencyStatus.currency) }
val fiatFee = feeFiatValue.format { fiat(appCurrency.code, appCurrency.symbol) }
uiState.update { val feeFiatValue = feeCryptoCurrencyStatus.value.fiatRate?.let { rate ->
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { feeCryptoValue?.multiply(rate)
it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) }
} else { val cryptoFee = feeCryptoValue.format { crypto(feeCryptoCurrencyStatus.currency) }
it.copy( val fiatFee = feeFiatValue.format { fiat(appCurrency.code, appCurrency.symbol) }
isPrimaryButtonEnabled = true,
yieldSupplyFeeUM = YieldSupplyFeeUM.Content( uiState.update {
transactionDataList = persistentListOf(approvalTransitionData.copy(fee = fee.normal)), if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) {
feeValue = combinedReference( it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading)
stringReference(cryptoFee), } else {
stringReference(" $DOT "), it.copy(
stringReference(fiatFee), isPrimaryButtonEnabled = true,
), yieldSupplyFeeUM = YieldSupplyFeeUM.Content(
currentNetworkFeeValue = TextReference.EMPTY, transactionDataList = persistentListOf(
maxNetworkFeeValue = TextReference.EMPTY, approvalTransitionData.copy(fee = transactionFee.normal),
), ),
) feeValue = combinedReference(
} stringReference(cryptoFee),
} stringReference(" $DOT "),
yieldSupplyNotificationsUpdateTrigger.triggerUpdate( stringReference(fiatFee),
data = YieldSupplyNotificationData( ),
feeValue = feeCryptoValue, currentNetworkFeeValue = TextReference.EMPTY,
feeError = null, maxNetworkFeeValue = TextReference.EMPTY,
minAmountFeeValue = TextReference.EMPTY,
), ),
) )
}, }
}
yieldSupplyNotificationsUpdateTrigger.triggerUpdate(
data = YieldSupplyNotificationData(
feeValue = feeCryptoValue,
feeError = null,
),
) )
} }

View file

@ -29,6 +29,7 @@ import com.tangem.features.yield.supply.impl.common.ui.YieldSupplyFeeRow
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable @Composable
internal fun YieldSupplyFeePolicyContent( internal fun YieldSupplyFeePolicyContent(
yieldSupplyFeeUM: YieldSupplyFeeUM, yieldSupplyFeeUM: YieldSupplyFeeUM,
@ -61,6 +62,28 @@ internal fun YieldSupplyFeePolicyContent(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
SpacerH24() SpacerH24()
FooterContainer(
footer = resourceReference(
id = R.string.yield_module_fee_policy_sheet_min_amount_note,
formatArgs = wrappedList(networkName),
),
paddingValues = PaddingValues(
top = 8.dp,
start = 12.dp,
end = 12.dp,
),
) {
val minAmount = when (yieldSupplyFeeUM) {
is YieldSupplyFeeUM.Content -> yieldSupplyFeeUM.minAmountFeeValue
YieldSupplyFeeUM.Error -> stringReference(StringsSigns.DASH_SIGN)
YieldSupplyFeeUM.Loading -> null
}
YieldSupplyFeeRow(
title = resourceReference(R.string.yield_module_fee_policy_sheet_min_amount_title),
value = minAmount,
)
}
SpacerH16()
FooterContainer( FooterContainer(
footer = resourceReference( footer = resourceReference(
id = R.string.yield_module_fee_policy_sheet_current_fee_note, id = R.string.yield_module_fee_policy_sheet_current_fee_note,
@ -101,6 +124,18 @@ internal fun YieldSupplyFeePolicyContent(
value = maxFee, value = maxFee,
) )
} }
Text(
text = stringResourceSafe(R.string.yield_module_fee_policy_tangem_service_fee_title),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
top = 8.dp,
start = 12.dp,
end = 12.dp,
),
)
} }
} }
@ -116,11 +151,11 @@ private fun YieldSupplyFeePolicyContent_Preview() {
feeValue = stringReference("0.0001 ETH • \$1.45"), feeValue = stringReference("0.0001 ETH • \$1.45"),
maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"), maxNetworkFeeValue = stringReference("8.50 USDT • \$8.50"),
currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"), currentNetworkFeeValue = stringReference("1.45 USDT • \$1.45"),
minAmountFeeValue = stringReference("50 USDT • \$50"),
), ),
tokenSymbol = "USDT", tokenSymbol = "USDT",
networkName = "Ethereum", networkName = "Ethereum",
modifier = Modifier.background(TangemTheme.colors.background.primary), modifier = Modifier.background(TangemTheme.colors.background.primary),
) )
} }
} }

View file

@ -117,6 +117,7 @@ internal class YieldSupplyStartEarningComponent(
onClick = model::onClick, onClick = model::onClick,
enabled = state.isPrimaryButtonEnabled, enabled = state.isPrimaryButtonEnabled,
iconResId = icon, iconResId = icon,
showProgress = state.isTransactionSending,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),

View file

@ -22,6 +22,7 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase
import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory
@ -61,11 +62,13 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory,
private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase,
private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
) : Model(), YieldSupplyNotificationsComponent.ModelCallback { ) : Model(), YieldSupplyNotificationsComponent.ModelCallback {
private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require()
private val cryptoCurrency = params.cryptoCurrency private val cryptoCurrency = params.cryptoCurrency
private var minAmount: BigDecimal by Delegates.notNull()
var userWallet: UserWallet by Delegates.notNull() var userWallet: UserWallet by Delegates.notNull()
val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus> val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
@ -112,6 +115,14 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
} }
} }
private suspend fun calculateMinAmount(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus) {
yieldSupplyMinAmountUseCase(userWallet, cryptoCurrencyStatus).onRight {
minAmount = it
}.onLeft {
minAmount = BigDecimal.ZERO
}
}
private suspend fun getMaxFee(): BigDecimal? { private suspend fun getMaxFee(): BigDecimal? {
if (uiState.value.maxFee != BigDecimal.ZERO) return uiState.value.maxFee if (uiState.value.maxFee != BigDecimal.ZERO) return uiState.value.maxFee
val yieldTokenStatus = yieldSupplyGetTokenStatusUseCase(cryptoCurrency as CryptoCurrency.Token) val yieldTokenStatus = yieldSupplyGetTokenStatusUseCase(cryptoCurrency as CryptoCurrency.Token)
@ -166,6 +177,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
updatedTransactionList = updatedTransactionList, updatedTransactionList = updatedTransactionList,
feeValue = feeSum, feeValue = feeSum,
maxNetworkFee = maxFee, maxNetworkFee = maxFee,
minAmount = minAmount,
), ),
) )
yieldSupplyNotificationsUpdateTrigger.triggerUpdate( yieldSupplyNotificationsUpdateTrigger.triggerUpdate(
@ -276,6 +288,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
feeCryptoCurrencyStatusFlow.update { feeCurrencyStatus } feeCryptoCurrencyStatusFlow.update { feeCurrencyStatus }
modelScope.launch { modelScope.launch {
calculateMinAmount(userWallet, currencyStatus)
onLoadFee() onLoadFee()
} }
} }

View file

@ -10,12 +10,14 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
import com.tangem.features.yield.supply.impl.common.formatter.YieldSupplyMinAmountFormatter
import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@Suppress("LongParameterList")
internal class YieldSupplyStartEarningFeeContentTransformer( internal class YieldSupplyStartEarningFeeContentTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
@ -23,6 +25,7 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
private val updatedTransactionList: List<TransactionData.Uncompiled>, private val updatedTransactionList: List<TransactionData.Uncompiled>,
private val feeValue: BigDecimal, private val feeValue: BigDecimal,
private val maxNetworkFee: BigDecimal, private val maxNetworkFee: BigDecimal,
private val minAmount: BigDecimal,
) : Transformer<YieldSupplyActionUM> { ) : Transformer<YieldSupplyActionUM> {
override fun transform(prevState: YieldSupplyActionUM): YieldSupplyActionUM { override fun transform(prevState: YieldSupplyActionUM): YieldSupplyActionUM {
val cryptoCurrency = cryptoCurrencyStatus.currency val cryptoCurrency = cryptoCurrencyStatus.currency
@ -46,6 +49,11 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
} }
val maxFiatFee = maxFiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) } val maxFiatFee = maxFiatFeeValue.format { fiat(appCurrency.code, appCurrency.symbol) }
val minAmountTextReference = YieldSupplyMinAmountFormatter(
cryptoCurrency,
appCurrency,
).invoke(minAmount, cryptoCurrencyStatus.value.fiatRate)
return if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { return if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) {
prevState.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading) prevState.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading)
} else { } else {
@ -67,6 +75,7 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
stringReference(" $DOT "), stringReference(" $DOT "),
stringReference(maxFiatFee), stringReference(maxFiatFee),
), ),
minAmountFeeValue = minAmountTextReference,
), ),
) )
} }

View file

@ -92,6 +92,7 @@ internal class YieldSupplyStopEarningComponent(
onClick = model::onClick, onClick = model::onClick,
iconResId = walletInterationIcon(params.userWallet), iconResId = walletInterationIcon(params.userWallet),
enabled = state.isPrimaryButtonEnabled, enabled = state.isPrimaryButtonEnabled,
showProgress = state.isTransactionSending,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),

View file

@ -12,6 +12,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase
@ -51,6 +52,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor(
private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger,
private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory,
private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
) : Model(), YieldSupplyNotificationsComponent.ModelCallback { ) : Model(), YieldSupplyNotificationsComponent.ModelCallback {
private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require()
@ -136,6 +138,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor(
) )
}, },
ifRight = { ifRight = {
fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id)
yieldSupplyDeactivateUseCase(cryptoCurrency) yieldSupplyDeactivateUseCase(cryptoCurrency)
params.callback.onTransactionSent() params.callback.onTransactionSent()
}, },

View file

@ -44,6 +44,7 @@ internal class YieldSupplyStopEarningFeeContentTransformer(
), ),
currentNetworkFeeValue = TextReference.EMPTY, currentNetworkFeeValue = TextReference.EMPTY,
maxNetworkFeeValue = TextReference.EMPTY, maxNetworkFeeValue = TextReference.EMPTY,
minAmountFeeValue = TextReference.EMPTY,
), ),
) )
} }