Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 09:13:23 +02:00
commit 81bf4b5296
128 changed files with 4244 additions and 477 deletions

View file

@ -83,7 +83,7 @@ internal class AddFundsModel @Inject constructor(
) { actionsState, isAccountMode ->
CryptoCurrencyData(
userWallet = request.userWallet,
status = request.status,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
isAccountMode = isAccountMode,
account = request.account,

View file

@ -9,7 +9,7 @@ class GetPushNotificationsDoubleAskVariantUseCase @Inject constructor(
private val abTestsManager: ABTestsManager,
) {
operator fun invoke(): DoubleAskVariant {
suspend operator fun invoke(): DoubleAskVariant {
if (!pushNotificationsFeatureToggles.isOnboardingPushDoubleAskAbEnabled) {
return DoubleAskVariant.Off
}

View file

@ -79,13 +79,15 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch { proceedAfterLater() }
return
}
val variant = getPushNotificationsDoubleAskVariantUseCase()
resolvedVariant = variant.key
if (variant == DoubleAskVariant.On) {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant))
_isDoubleAskSheetShown.value = true
} else {
modelScope.launch { proceedAfterLater() }
modelScope.launch {
val variant = getPushNotificationsDoubleAskVariantUseCase()
resolvedVariant = variant.key
if (variant == DoubleAskVariant.On) {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant))
_isDoubleAskSheetShown.value = true
} else {
proceedAfterLater()
}
}
}

View file

@ -3,9 +3,11 @@ package com.tangem.features.pushnotifications.impl.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class GetPushNotificationsDoubleAskVariantUseCaseTest {
@ -19,38 +21,38 @@ internal class GetPushNotificationsDoubleAskVariantUseCaseTest {
)
@Test
fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() {
fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() = runTest {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns false
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.Off)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() {
fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() = runTest {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "treatment"
coEvery { abTestsManager.getValue(KEY, "control") } returns "treatment"
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.On)
verify(exactly = 1) { abTestsManager.getValue(KEY, "control") }
coVerify(exactly = 1) { abTestsManager.getValue(KEY, "control") }
}
@Test
fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() {
fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() = runTest {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "control"
coEvery { abTestsManager.getValue(KEY, "control") } returns "control"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}
@Test
fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() {
fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() = runTest {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "unexpected_value"
coEvery { abTestsManager.getValue(KEY, "control") } returns "unexpected_value"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}

View file

@ -19,8 +19,8 @@ import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDou
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -49,12 +49,12 @@ internal class PushNotificationsModelTest {
@BeforeEach
fun setUp() {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
}
@Test
fun `GIVEN onboarding treatment WHEN onLaterClick THEN double ask shown and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
@ -75,7 +75,7 @@ internal class PushNotificationsModelTest {
@Test
fun `GIVEN onboarding control WHEN onLaterClick THEN proceeds without double ask`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
val model = createModel(testScope = this)
advanceUntilIdle()
@ -104,13 +104,13 @@ internal class PushNotificationsModelTest {
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
verify(exactly = 0) { getDoubleAskVariantUseCase() }
coVerify(exactly = 0) { getDoubleAskVariantUseCase() }
verify { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskEnableClick THEN enable tapped sent and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
@ -128,7 +128,7 @@ internal class PushNotificationsModelTest {
@Test
fun `GIVEN double ask shown WHEN onDoubleAskSkipClick THEN event sent and proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
@ -146,7 +146,7 @@ internal class PushNotificationsModelTest {
@Test
fun `GIVEN double ask shown WHEN onDoubleAskDismiss THEN sheet hidden and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.stringResourceSafe
@ -54,6 +55,7 @@ private fun UnratedState(state: RatingUM.RatingState.Unrated, onRatingSelect: (I
text = stringResourceSafe(R.string.swapping_rate_experience_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
StarRow(
@ -69,6 +71,7 @@ private fun AlreadyRatedState(rating: Int) {
text = stringResourceSafe(R.string.swapping_rate_experience_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
StarRow(

View file

@ -8,6 +8,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
@ -70,12 +71,12 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor(
return@launch
}
val availability = getStakingAvailabilityUseCase.invokeSync(
val availability: StakingAvailability? = getStakingAvailabilityUseCase.invokeSync(
userWalletId = selectedUserWalletId,
cryptoCurrency = cryptoCurrency,
).getOrNull()
val option = (availability as? StakingAvailability.Available)?.option
val option = availability?.optionOrNull
if (option == null) {
TangemLogger.e("Staking is unavailable for ${cryptoCurrency.name}")
return@launch

View file

@ -97,6 +97,7 @@ import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstake
import com.tangem.lib.crypto.BlockchainUtils.isTon
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.isPositive
import com.tangem.utils.extensions.isSingleItem
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
@ -642,6 +643,31 @@ internal class StakingModel @Inject constructor(
integration = integration,
),
)
checkSumLimitExceeded()
}
private fun checkSumLimitExceeded() {
val maxLimit = (integration as? P2PEthPoolIntegration)
?.enterArgs?.amountRequirement?.maximum
?.takeIf { it.isPositive() }
?: return
val enteredAmount = (uiState.value.amountState as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value
?: return
if (enteredAmount > maxLimit) {
// Pass the max limit as a stable crypto-formatted value (e.g. "0.15 ETH"); the event
// builds the hardcoded English "Error Message" from it, so the model needs no resources.
val formattedMax = maxLimit.format { crypto(cryptoCurrencyStatus.currency) }
analyticsEventHandler.send(
StakingAnalyticsEvent.SumLimitError(
token = cryptoCurrencyStatus.currency.symbol,
blockchain = cryptoCurrencyStatus.currency.network.name,
maxAmount = formattedMax,
),
)
}
}
override fun onAmountPasteTriggerDismiss() {
@ -658,6 +684,7 @@ internal class StakingModel @Inject constructor(
integration = integration,
),
)
checkSumLimitExceeded()
}
override fun onCurrencyChangeClick(isFiat: Boolean) {
@ -1099,7 +1126,12 @@ internal class StakingModel @Inject constructor(
}
override fun showPrimaryClickAlert() {
messageSender.send(StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency))
val message = if (integration is P2PEthPoolIntegration) {
StakingAlertUM.stakeMoreClickUnavailableNoTargets()
} else {
StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency)
}
messageSender.send(message)
}
override fun onOpenLearnMoreAboutApproveClick() {

View file

@ -54,6 +54,11 @@ internal object StakingAlertUM {
),
)
fun stakeMoreClickUnavailableNoTargets(): DialogMessage = DialogMessage(
title = null,
message = resourceReference(R.string.staking_no_validators_error_message),
)
fun rewardsMinimumRequirementsError(cryptoCurrencyName: String, cryptoAmountValue: String): DialogMessage =
DialogMessage(
title = null,

View file

@ -1,6 +1,5 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.remove
import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
@ -20,7 +19,6 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.P2PEthPoolIntegration
import com.tangem.domain.staking.model.Period
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
@ -37,6 +35,7 @@ import com.tangem.features.staking.impl.presentation.state.converters.RewardsVal
import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter
import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText
import com.tangem.features.staking.impl.presentation.state.utils.toTextReference
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.isNullOrZero
@ -178,8 +177,8 @@ internal class SetInitialDataStateTransformer(
cryptoCurrencyStatus: CryptoCurrencyStatus,
): RoundedListWithDividersItemData? {
val minimumCryptoAmount = integration.enterMinimumAmount ?: return null
val blockchainId = cryptoCurrencyStatus.currency.network.rawId
if (!showMinimumRequirementInfo(blockchainId)) return null
val networkId = cryptoCurrencyStatus.currency.network.rawId
if (!showMinimumRequirementInfo(networkId)) return null
val formattedAmount = minimumCryptoAmount.format { crypto(cryptoCurrencyStatus.currency) }
@ -262,7 +261,6 @@ internal class SetInitialDataStateTransformer(
walletTitle = stringReference(userWalletProvider().name),
prefixText = resourceReference(R.string.common_from),
).convert(account),
isMaxButtonVisible = integration !is P2PEthPoolIntegration,
).convert(
AmountParameters(
title = stringReference(userWalletProvider().name),
@ -301,8 +299,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun showMinimumRequirementInfo(blockchainId: String): Boolean {
return blockchainId == Blockchain.Polkadot.id || blockchainId == Blockchain.Cardano.id
private fun showMinimumRequirementInfo(networkId: String): Boolean {
return BlockchainUtils.isPolkadot(networkId) || BlockchainUtils.isCardano(networkId)
}
private companion object {

View file

@ -96,12 +96,18 @@ internal class AmountRequirementStateTransformer(
return when (actionType) {
is StakingActionCommonType.Enter -> {
val enterRequirements = integration.enterArgs?.amountRequirement
enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error)
integration.enterArgs?.amountRequirement?.getError(
amount = amountDecimal,
minErrorRes = R.string.staking_amount_requirement_error,
maxErrorRes = R.string.staking_max_amount_requirement_error,
)
}
is StakingActionCommonType.Exit -> {
val exitRequirements = integration.exitArgs?.amountRequirement
exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error)
integration.exitArgs?.amountRequirement?.getError(
amount = amountDecimal,
minErrorRes = R.string.staking_unstake_amount_requirement_error,
maxErrorRes = R.string.staking_max_amount_requirement_error,
)
}
else -> null
}
@ -118,31 +124,25 @@ internal class AmountRequirementStateTransformer(
return isEnterOrExit && isTron && !isIntegerOnly
}
private fun StakingAmountRequirement.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? {
private fun StakingAmountRequirement.getError(
amount: BigDecimal,
@StringRes minErrorRes: Int,
@StringRes maxErrorRes: Int,
): TextReference? {
if (!isRequired) return null
val isExceedsMinRequirement = minimum?.compareTo(amount) == 1
val isExceedsMaxRequirement = if (maximum?.isPositive() == true) {
maximum?.compareTo(amount) == -1
} else {
maxAmount.amount?.compareTo(amount) == -1
val effectiveMax = maximum?.takeIf { it.isPositive() } ?: maxAmount.amount
val isExceedsMaxRequirement = effectiveMax?.compareTo(amount) == -1
val (errorRes, boundary) = when {
isExceedsMinRequirement -> minErrorRes to minimum
isExceedsMaxRequirement -> maxErrorRes to effectiveMax
else -> return null
}
val errorText = when {
isExceedsMinRequirement -> {
minimum.format {
crypto(cryptoCurrencyStatus.currency)
}
}
isExceedsMaxRequirement -> {
maximum.format {
crypto(cryptoCurrencyStatus.currency)
}
}
else -> ""
}
return resourceReference(
errorTextRes,
wrappedList(errorText),
).takeIf { isRequired && (isExceedsMinRequirement || isExceedsMaxRequirement) }
val formatted = boundary.format { crypto(cryptoCurrencyStatus.currency) }
return resourceReference(errorRes, wrappedList(formatted))
}
data class Data(

View file

@ -0,0 +1,131 @@
package com.tangem.features.staking.impl.presentation.model
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.staking.analytics.StakingAnalyticsEvent
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.tokens.model.Amount
import com.tangem.features.staking.impl.presentation.state.StakingStep
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Model-level tests for the P2P ETH pool staking integration:
* verifies that [StakingAnalyticsEvent.SumLimitError] is sent when the entered amount
* exceeds the vault's computed maximum (= limit totalAssets).
*
* Fixture:
* vault address = "0xabc" totalAssets = 5
* limit "0xabc" limit = 10
* maximum = 10 5 = 5.0 (scale=1, RoundingMode.FLOOR)
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class StakingModelP2PSumLimitTest : StakingModelTestBase() {
override val testIntegrationId: StakingIntegrationID = StakingIntegrationID.P2PEthPool
private val vaultAddress = "0xabc"
private val testVault = P2PEthPoolVault(
vaultAddress = vaultAddress,
displayName = "Test Vault",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal("1000"),
totalAssets = BigDecimal("5"),
feePercent = BigDecimal("0.1"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = false,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
// maximum = 10 5 = 5.0 (FLOOR scale=1)
private val testLimits = mapOf(
vaultAddress to VaultLimitInfo(limit = BigDecimal("10"), coefficient = null),
)
@BeforeEach
fun setUpP2P() {
coEvery { p2pEthPoolRepository.getVaultsSync() } returns listOf(testVault)
coEvery { p2pEthPoolRepository.getVaultLimitsSyncOrNull() } returns testLimits
}
/**
* Helper: returns a [MutableStateFlow] whose value has [amountState] set to an
* [AmountState.Data] mock with the given [cryptoAmountValue].
* The flow is also wired to [stateController.uiState].
*/
private fun stubUiStateWithCryptoAmount(cryptoAmountValue: BigDecimal): MutableStateFlow<StakingUiState> {
val amountData = mockk<AmountState.Data>(relaxed = true) {
every { amountTextField } returns mockk(relaxed = true) {
every { cryptoAmount } returns Amount(
currencySymbol = "ETH",
value = cryptoAmountValue,
decimals = 18,
)
}
}
val uiStateFlow = MutableStateFlow(
mockk<StakingUiState>(relaxed = true) {
every { currentStep } returns StakingStep.InitialInfo
every { amountState } returns amountData
},
)
every { stateController.uiState } returns uiStateFlow
return uiStateFlow
}
// ----- Test A ---------------------------------------------------------------
@Test
fun `GIVEN P2P vault max=5 WHEN amount 6 entered THEN SumLimitError analytics sent`() = runTest {
stubUiStateWithCryptoAmount(BigDecimal("6"))
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAmountValueChange("6")
verify {
analyticsEventHandler.send(
match { it is StakingAnalyticsEvent.SumLimitError }
)
}
model.onDestroy()
}
// ----- Test B ---------------------------------------------------------------
@Test
fun `GIVEN P2P vault max=5 WHEN amount 4 entered THEN SumLimitError analytics NOT sent`() = runTest {
stubUiStateWithCryptoAmount(BigDecimal("4"))
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAmountValueChange("4")
verify(exactly = 0) {
analyticsEventHandler.send(
match { it is StakingAnalyticsEvent.SumLimitError }
)
}
model.onDestroy()
}
}

View file

@ -57,8 +57,8 @@ internal abstract class StakingModelTestBase {
protected val testUserWalletId = UserWalletId("1234567890ABCDEF")
protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true)
private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana
private val testParams = StakingComponent.Params(
protected open val testIntegrationId: StakingIntegrationID = StakingIntegrationID.StakeKit.Coin.Solana
private val testParams get() = StakingComponent.Params(
userWalletId = testUserWalletId,
cryptoCurrency = testCryptoCurrency,
integrationId = testIntegrationId,

View file

@ -0,0 +1,31 @@
package com.tangem.features.staking.impl.presentation.state.events
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.staking.impl.R
import io.mockk.mockk
import org.junit.jupiter.api.Test
internal class StakingAlertUMTest {
@Test
fun `noTargets dialog uses no validators string and has no title`() {
val message = StakingAlertUM.stakeMoreClickUnavailableNoTargets()
assertThat(message.title).isNull()
assertThat((message.message as TextReference.Res).id)
.isEqualTo(R.string.staking_no_validators_error_message)
}
@Test
fun `default stake more dialog uses stake more unavailability string`() {
val currency: CryptoCurrency = mockk(relaxed = true)
val message = StakingAlertUM.stakeMoreClickUnavailable(currency)
assertThat(message.title).isNull()
assertThat((message.message as TextReference.Res).id)
.isEqualTo(R.string.staking_stake_more_button_unavailability_reason)
}
}

View file

@ -0,0 +1,152 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.Amount
import com.tangem.features.staking.impl.R
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class AmountRequirementStateTransformerTest {
private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true)
private fun amountState(enteredCrypto: BigDecimal): AmountState.Data = AmountState.Data(
isPrimaryButtonEnabled = true,
accountTitleUM = mockk(relaxed = true),
availableBalanceCrypto = mockk(relaxed = true),
availableBalanceFiat = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
amountTextField = AmountFieldModel(
value = enteredCrypto.toPlainString(),
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = Amount(currencySymbol = "ETH", value = enteredCrypto, decimals = 18),
fiatAmount = Amount(currencySymbol = "USD", value = BigDecimal.ZERO, decimals = 2),
isFiatValue = false,
fiatValue = "0",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = stringReference(""),
),
appCurrency = mockk(relaxed = true),
)
private fun enterIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk {
every { enterArgs } returns StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = minimum,
maximum = maximum,
),
isPartialAmountDisabled = false,
)
}
private fun exitIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk {
every { exitArgs } returns StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = minimum,
maximum = maximum,
),
isPartialAmountDisabled = false,
)
}
@Test
fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null),
integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = BigDecimal("0.15")),
actionType = StakingActionCommonType.Enter(skipEnterAmount = false),
)
val result = transformer.transform(amountState(BigDecimal("0.2"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_max_amount_requirement_error)
}
@Test
fun `WHEN amount below minimum THEN min amount error string is used`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null),
integration = enterIntegrationWith(minimum = BigDecimal("0.1"), maximum = null),
actionType = StakingActionCommonType.Enter(skipEnterAmount = false),
)
val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_amount_requirement_error)
}
@Test
fun `WHEN Exit action and amount below exit minimum THEN unstake min error string is used`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null),
integration = exitIntegrationWith(minimum = BigDecimal("0.1"), maximum = null),
actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false),
)
val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_unstake_amount_requirement_error)
}
@Test
fun `WHEN Enter action and maximum is null and amount exceeds balance cap THEN max error string is used`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null),
integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = null),
actionType = StakingActionCommonType.Enter(skipEnterAmount = false),
)
val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_max_amount_requirement_error)
}
@Test
fun `WHEN Exit action and amount exceeds staked balance THEN max amount error string is used`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null),
integration = exitIntegrationWith(minimum = BigDecimal("0.01"), maximum = null),
actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false),
)
val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_max_amount_requirement_error)
}
}

View file

@ -21,8 +21,8 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -69,6 +69,7 @@ import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.transformers.S
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.transformers.SendWithSwapConfirmationNotificationsTransformer
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.lib.crypto.BlockchainFeeUtils.patchTransactionFeeForSwap
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import jakarta.inject.Inject
@ -99,6 +100,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val swapAlertFactory: SwapAlertFactory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appScope: AppCoroutineScope,
swapTransactionSenderFactory: SwapTransactionSender.Factory,
paramsContainer: ParamsContainer,
) : Model(), FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback {
@ -368,7 +370,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
txHash = txHash,
currency = primaryCurrencyStatus.currency,
).getOrNull().orEmpty()
modelScope.launch(dispatchers.default) { sendSuccessAnalytics() }
appScope.launch(dispatchers.default) { sendSuccessAnalytics() }
uiState.transformerUpdate(
SendWithSwapConfirmSentStateTransformer(
timestamp = timestamp,

View file

@ -19,7 +19,6 @@ interface SwapComponent : ComposableContentComponent {
val cryptoAmount: BigDecimal,
val fiatAmount: BigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
/** Preferred position of the pre-selected currency on the swap screen. */

View file

@ -56,12 +56,14 @@ internal class DefaultSwapFeedbackRepository(
add(SurveySparrowAnswerDto(feedbackQuestionId, params.feedback))
}
},
variables = mapOf(
"tx_external_id" to params.txExternalId,
"provider_name" to params.providerName,
"tx_url" to params.txUrl,
"user_wallet_id" to params.userWalletIdHash,
),
variables = buildMap {
put("tx_external_id", params.txExternalId)
put("provider_name", params.providerName)
if (params.txUrl.isNotEmpty()) {
put("tx_url", params.txUrl)
}
put("user_wallet_id", params.userWalletIdHash)
},
),
)
}

View file

@ -35,6 +35,12 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
fun extractFromSwapCurrencyFromPair(
pair: SwapPairLeast,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapCurrencyStatus?
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromSwapCurrencyStatus: SwapCurrencyStatus,
@ -109,6 +115,10 @@ interface SwapInteractor {
* Delegates to `DexSwapFeeCalculator` for DEX/DEX_BRIDGE or to `CexSwapFeeCalculator` for CEX,
* then wraps the result in a [SwapFee].
*
* Flow is resolved by [txType], matching the quote-stage `resolveQuoteFlow`: a DEX/DEX_BRIDGE
* provider whose quote returned `txType=SEND` (swap-xyz native transfer) takes the CEX-style
* fee path even though [swapData] is `null`. `txType=SWAP`/`null` keeps the DEX path.
*
* The DEX path consumes the pre-fetched [swapData] (which carries the `ExpressTransactionModel.DEX` payload);
* the CEX path computes the fee directly from `amount`.
* When [swapData] is `null` on the DEX path the call short-circuits to `Left(GetFeeError.UnknownError)`
@ -133,6 +143,7 @@ interface SwapInteractor {
swapData: SwapDataModel?,
selectedFeeToken: CryptoCurrencyStatus?,
isGasless: Boolean,
txType: ExpressTxType? = null,
): Either<GetFeeError, SwapFee>
fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String)

View file

@ -181,6 +181,25 @@ internal class SwapInteractorImpl @Inject constructor(
}?.providers.orEmpty()
}
override fun extractFromSwapCurrencyFromPair(
pair: SwapPairLeast,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapCurrencyStatus? {
return if (pair.from.network == fromSwapCurrencyStatus.currency.network.rawId &&
pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress()
) {
fromSwapCurrencyStatus
} else if (
pair.from.network == toSwapCurrencyStatus.currency.network.rawId &&
pair.from.contractAddress == toSwapCurrencyStatus.currency.getContractAddress()
) {
toSwapCurrencyStatus
} else {
null
}
}
override suspend fun findProvidersForPairWithCheck(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -1164,20 +1183,19 @@ internal class SwapInteractorImpl @Inject constructor(
swapData: SwapDataModel?,
selectedFeeToken: CryptoCurrencyStatus?,
isGasless: Boolean,
txType: ExpressTxType?,
): Either<GetFeeError, SwapFee> = either {
if (amount.value.signum() == 0) {
raise(GetFeeError.UnknownError)
}
return when (quotesLoadedState.swapProvider.type) {
ExchangeProviderType.DEX,
ExchangeProviderType.DEX_BRIDGE,
-> loadDexSwapFee(
return when (resolveQuoteFlow(quotesLoadedState.swapProvider, txType)) {
ResolvedFlow.DexLike -> loadDexSwapFee(
fromStatus = fromStatus,
swapData = swapData,
selectedFeeToken = selectedFeeToken,
permissionState = quotesLoadedState.permissionState,
)
ExchangeProviderType.CEX -> loadCexSwapFee(
ResolvedFlow.CexLike -> loadCexSwapFee(
fromStatus = fromStatus,
amount = amount,
selectedFeeToken = selectedFeeToken,
@ -1619,6 +1637,7 @@ internal class SwapInteractorImpl @Inject constructor(
feeValue = BigDecimal.ZERO,
),
minAdaValue = null,
txType = quoteModel.txType,
)
when (resolveQuoteFlow(provider, quoteModel.txType)) {

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap.domain.models.domain
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import java.math.BigDecimal
/**
@ -18,11 +17,6 @@ data class SwapPairLeast(
val providers: List<SwapProvider>,
)
data class CryptoCurrencySwapInfo(
val currencyStatus: CryptoCurrencyStatus,
val providers: List<SwapProvider>,
)
/**
* Provider that could swap given cryptocurrencies
*

View file

@ -12,6 +12,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
@ -39,7 +40,7 @@ sealed interface SwapState {
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal?,
val txType: ExpressTxType? = null,
) : SwapState
data class Transfer(

View file

@ -7,9 +7,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@ -34,7 +32,7 @@ internal class GetSwapUiModeUseCaseTest {
assertThat(actual).isEqualTo(SwapUIMode.Detailed)
coVerify(exactly = 0) { swapRepository.getStoredSwapUiMode() }
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
@ -46,7 +44,7 @@ internal class GetSwapUiModeUseCaseTest {
val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Detailed)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
@ -58,7 +56,7 @@ internal class GetSwapUiModeUseCaseTest {
val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Simple)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
@ -66,24 +64,24 @@ internal class GetSwapUiModeUseCaseTest {
runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed"
coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed"
val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Detailed)
verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
}
@Test
fun `GIVEN toggle enabled and repository empty and AB returns simple WHEN invoke THEN returns Simple`() = runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple"
coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple"
val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Simple)
verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
}
@Test
@ -91,7 +89,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE"
coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE"
val actual = sut.invoke()
@ -103,7 +101,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else"
coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else"
val actual = sut.invoke()
@ -115,7 +113,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns ""
coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns ""
val actual = sut.invoke()

View file

@ -0,0 +1,352 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.utils.extensions.filterIf
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for the Tangem Pay provider-filtering logic that lives in
* `SwapModel.filterTangemPayProviders` (private extension on `List<SwapPairLeast>`).
*
* Because `SwapModel` is a `@ModelScoped` Decompose class with ~30 constructor dependencies
* and requires a Decompose component context, it cannot be instantiated in a unit test.
* Instead, we verify the *algorithm* end-to-end:
*
* 1. [SwapInteractorImpl.extractFromSwapCurrencyFromPair] resolves which
* [SwapCurrencyStatus] is the FROM side of a given pair.
* 2. `isTangemPayWithdrawal(status) = status?.account is Account.Payment` the check.
* 3. `List.filterIf(isWithdrawal) { provider.type == CEX }` the filtering.
*
* We exercise all three together in test-space so that every business rule of
* `filterTangemPayProviders` is covered, including all 9 edge cases from the task spec.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("filterTangemPayProviders — Payment-account provider filtering logic")
internal class SwapFilterTangemPayProvidersLogicTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
private val polygonNetwork = Blockchain.Polygon.toNetworkId()
private val userWalletId = UserWalletId(stringValue = "deadbeef")
// -----------------------------------------------------------------------
// Helpers — mirrors the private logic in SwapModel.filterTangemPayProviders
// -----------------------------------------------------------------------
/**
* Pure reimplementation of `SwapModel.filterTangemPayProviders` that delegates
* to the real [SwapInteractorImpl.extractFromSwapCurrencyFromPair] for the
* FROM-side resolution. This lets every unit test exercise the *exact same*
* algorithm as the production code without instantiating `SwapModel`.
*/
private fun List<SwapPairLeast>.applyTangemPayFilter(
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
): List<SwapPairLeast> = map { pair ->
val resolvedFrom = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
val isTangemPayWithdrawal = resolvedFrom?.account is Account.Payment
val filterProviderTypes = if (isTangemPayWithdrawal) {
listOf(ExchangeProviderType.CEX)
} else {
emptyList()
}
pair.copy(
providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider ->
provider.type in filterProviderTypes
},
)
}
// -----------------------------------------------------------------------
// Builders
// -----------------------------------------------------------------------
private fun buildPaymentStatus(
networkRawId: String = ethNetwork,
contractAddress: String = "0",
isCoin: Boolean = true,
): SwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = networkRawId,
contractAddress = contractAddress,
isCoin = isCoin,
).copy(account = Account.Payment(userWalletId))
private fun buildCryptoPortfolioStatus(
networkRawId: String = ethNetwork,
contractAddress: String = "0",
isCoin: Boolean = true,
): SwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = networkRawId,
contractAddress = contractAddress,
isCoin = isCoin,
).copy(account = Account.CryptoPortfolio.createMainAccount(userWalletId))
private fun mixedProviders() = listOf(
buildSwapProvider(ExchangeProviderType.CEX, "cex-1"),
buildSwapProvider(ExchangeProviderType.DEX, "dex-1"),
buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "bridge-1"),
)
private fun cexOnlyProviders() = listOf(
buildSwapProvider(ExchangeProviderType.CEX, "cex-only"),
)
private fun dexOnlyProviders() = listOf(
buildSwapProvider(ExchangeProviderType.DEX, "dex-only"),
)
// -----------------------------------------------------------------------
// Test cases
// -----------------------------------------------------------------------
@Nested
@DisplayName("Payment account FROM side — only CEX providers must remain")
inner class PaymentAccountFromSide {
@Test
@DisplayName("should keep only CEX when FROM status is Payment account and providers are mixed")
fun `should keep only CEX when FROM status is Payment account and providers are mixed`() {
// given — FROM is a Payment account, pair.from matches FROM
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — only CEX survives
assertThat(result).hasSize(1)
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
}
@Test
@DisplayName("should return empty providers when Payment account FROM and no CEX in list")
fun `should return empty providers when Payment account FROM and no CEX in list`() {
// given — FROM is Payment, no CEX provider exists
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = dexOnlyProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — all providers removed because none are CEX
assertThat(result[0].providers).isEmpty()
}
@Test
@DisplayName("should leave list unchanged when Payment account FROM and all providers already CEX")
fun `should leave list unchanged when Payment account FROM and all providers already CEX`() {
// given — FROM is Payment, list is already all CEX
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = cexOnlyProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — single CEX provider still present, unchanged
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
}
}
@Nested
@DisplayName("Non-Payment account — provider list must not be modified")
inner class NonPaymentAccount {
@Test
@DisplayName("should not filter providers when FROM status is CryptoPortfolio account")
fun `should not filter providers when FROM status is CryptoPortfolio account`() {
// given — FROM is a CryptoPortfolio account (regression guard)
val fromStatus = buildCryptoPortfolioStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — all 3 providers survive untouched
assertThat(result[0].providers).hasSize(3)
assertThat(result[0].providers.map { it.type })
.containsExactly(ExchangeProviderType.CEX, ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE)
}
}
@Nested
@DisplayName("Null resolved status — no filtering applied")
inner class NullResolvedStatus {
@Test
@DisplayName("should not filter when extractFromSwapCurrencyFromPair resolves null (unrelated pair)")
fun `should not filter when extractFromSwapCurrencyFromPair resolves null`() {
// given — pair.from is on an unrelated network (neither fromStatus nor toStatus)
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches neither
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — null status → isTangemPayWithdrawal=false → no filter applied
assertThat(result[0].providers).hasSize(3)
}
}
@Nested
@DisplayName("Empty inputs — no crash, stable output")
inner class EmptyInputs {
@Test
@DisplayName("should return empty list when input pairs list is empty")
fun `should return empty list when input pairs list is empty`() {
// given
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
// when
val result = emptyList<SwapPairLeast>().applyTangemPayFilter(fromStatus, toStatus)
// then
assertThat(result).isEmpty()
}
@Test
@DisplayName("should handle empty provider list on a pair without crashing")
fun `should handle empty provider list on a pair without crashing`() {
// given — Payment account FROM, but the pair already has an empty provider list
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = emptyList(),
)
// when
val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus)
// then — stays empty, no crash
assertThat(result[0].providers).isEmpty()
}
}
@Nested
@DisplayName("Multiple pairs — filtering applied per-pair independently")
inner class MultiplePairs {
@Test
@DisplayName("should filter only pairs whose resolved FROM is a Payment account")
fun `should filter only pairs whose resolved FROM is a Payment account`() {
// given — 2 pairs:
// pair1: pair.from == ethNetwork → fromStatus (Payment) → filter to CEX only
// pair2: pair.from == btcNetwork → toStatus (non-Payment) → no filter
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair1 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
val pair2 = buildSwapPairLeast(
fromNetwork = btcNetwork, // matches toStatus (CryptoPortfolio)
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
providers = mixedProviders(),
)
// when
val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus)
// then
// pair1 resolved to Payment account → only CEX remains
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
// pair2 resolved to CryptoPortfolio → all 3 providers intact
assertThat(result[1].providers).hasSize(3)
}
@Test
@DisplayName("should filter all pairs when all resolved FROM statuses are Payment accounts")
fun `should filter all pairs when all resolved FROM statuses are Payment accounts`() {
// given — both pairs have their pair.from matching the Payment account
val fromStatus = buildPaymentStatus(networkRawId = ethNetwork)
val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork)
val pair1 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = mixedProviders(),
)
val pair2 = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = polygonNetwork,
toContract = "0",
providers = dexOnlyProviders(),
)
// when
val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus)
// then — pair1: CEX kept; pair2: DEX removed → empty
assertThat(result[0].providers).hasSize(1)
assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX)
assertThat(result[1].providers).isEmpty()
}
}
}

View file

@ -0,0 +1,282 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [SwapInteractorImpl.extractFromSwapCurrencyFromPair].
*
* This function resolves which of the two [com.tangem.domain.swap.models.SwapCurrencyStatus]
* arguments corresponds to the `from` side of a given [com.tangem.feature.swap.domain.models.domain.SwapPairLeast].
*
* It is the building block behind the Tangem Pay provider-filtering logic in `SwapModel`:
* the resolved "from" currency status is inspected for an [com.tangem.domain.models.account.Account.Payment]
* account; when it belongs to a payment account, only CEX providers are kept for that pair.
*
* A pair is matched on both `network` (rawId) and `contractAddress` ("0" for coins, the token
* contract for tokens). The `from` side is checked first, then the `to` side, otherwise null.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplExtractFromSwapCurrencyTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
private val polygonNetwork = Blockchain.Polygon.toNetworkId()
@Nested
inner class MatchesFromSide {
@Test
fun `should return fromSwapCurrencyStatus when pair from matches the from coin by network and contract`() {
// Given — coin: getContractAddress() == "0"
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
@Test
fun `should return fromSwapCurrencyStatus when pair from matches the from token by network and contract`() {
// Given — token: getContractAddress() == contractAddress
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0xToken",
toNetwork = btcNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
}
@Nested
inner class MatchesToSide {
@Test
fun `should return toSwapCurrencyStatus when pair from matches the to side (reverse-direction pair)`() {
// Given — pair.from points at the toStatus currency, not the fromStatus
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = btcNetwork, // matches toStatus
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(toStatus)
}
@Test
fun `should return toSwapCurrencyStatus when pair from matches to token by network and contract`() {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(
networkRawId = polygonNetwork,
contractAddress = "0xUsdc",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches toStatus token
fromContract = "0xUsdc",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(toStatus)
}
}
@Nested
inner class NoMatch {
@Test
fun `should return null when pair from matches neither from nor to`() {
// Given — pair.from is on an unrelated network
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // matches neither
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
@Test
fun `should return null when network matches but contract address differs`() {
// Given — same eth network but different token contracts
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xAaa",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xBbb",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0xCcc", // matches neither contract
toNetwork = ethNetwork,
toContract = "0xAaa",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
@Test
fun `should return null when contract matches but network differs`() {
// Given — same contract address but on a different network than either status
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xShared",
isCoin = false,
)
val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0",
isCoin = true,
)
val pair = buildSwapPairLeast(
fromNetwork = polygonNetwork, // contract matches fromStatus but network does not
fromContract = "0xShared",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isNull()
}
}
@Nested
inner class Precedence {
@Test
fun `should prefer from side when both from and to would match the pair from`() {
// Given — both statuses are the same network+contract; from must win (checked first)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = ethNetwork,
toContract = "0",
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then — from side has precedence and is returned, not the to side
assertThat(result).isSameInstanceAs(fromStatus)
assertThat(result).isNotSameInstanceAs(toStatus)
}
@Test
fun `pair providers are irrelevant to the resolution`() {
// Given — provider list should not affect which currency status is extracted
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = listOf(
buildSwapProvider(ExchangeProviderType.DEX, "dex"),
buildSwapProvider(ExchangeProviderType.CEX, "cex"),
),
)
// When
val result = sut.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Then
assertThat(result).isSameInstanceAs(fromStatus)
}
}
}

View file

@ -16,6 +16,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.fee.CexFeeResult
import com.tangem.feature.swap.domain.fee.DexFeeResult
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
@ -253,6 +254,92 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
}
}
@Test
fun `DEX provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest {
// [REDACTED_TASK_KEY]: swap-xyz comes as provider.type=DEX but the quote returns txType=SEND, which
// re-routes to the CEX-style flow (no DEX swapData is built). Fee must load via the CEX
// calculator instead of short-circuiting to UnknownError.
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
}
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
} returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right()
val result = sut.loadSwapFee(
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
isGasless = false,
txType = ExpressTxType.SEND,
)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
@Test
fun `DEX_BRIDGE provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
}
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
} returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right()
val result = sut.loadSwapFee(
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
isGasless = false,
txType = ExpressTxType.SEND,
)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
@Test
fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = buildDexTransaction(),
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns GetFeeError.DataError(cause = ExpressDataError.UnknownError()).left()
val result = sut.loadSwapFee(
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null,
isGasless = false,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java)
}
}
// -------------------------------------------------------------------------
// CEX branch
// -------------------------------------------------------------------------

View file

@ -103,7 +103,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.5"),
selectedFeeToken = null, isGasless = true,
selectedFeeToken = null,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -141,7 +142,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null, isGasless = true,
selectedFeeToken = null,
isGasless = true,
)
assertThat(result.isLeft()).isTrue()
@ -176,7 +178,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("2.0"),
selectedFeeToken = tokenStatus, isGasless = true,
selectedFeeToken = tokenStatus,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -223,7 +226,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("3.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
assertThat(result.isRight()).isTrue()
@ -297,7 +301,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
assertThat(result.isLeft()).isTrue()
@ -342,7 +347,8 @@ internal class CexSwapFeeCalculatorTest {
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus, isGasless = true,
selectedFeeToken = coinStatus,
isGasless = true,
)
result.onRight { cexResult ->

View file

@ -148,6 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
val shouldHideBlock by remember {
derivedStateOf {
// TODO collapse this and move to model
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
val isInsufficientFunds = model.uiState.isInsufficientFunds
val isProviderMissing = dataState.selectedProvider == null

View file

@ -112,6 +112,7 @@ import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.filterIf
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.NonCancellable
@ -578,6 +579,7 @@ internal class SwapModel @Inject constructor(
}
}
@Suppress("LongMethod")
private fun onChangeCardsClicked() {
modelScope.launch {
singleTaskScheduler.cancelTask()
@ -595,7 +597,15 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
pairs = dataState.pairs,
selectedPairProviders = dataState.selectedPairProviders,
selectedPairProviders = if (newFromSwapCurrencyStatus == null || newToSwapCurrencyStatus == null) {
emptyList()
} else {
swapInteractor.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
pairs = dataState.pairs,
)
},
)
filterTokensFromSelector()
uiState = stateBuilder.updateCurrenciesState(
@ -671,11 +681,7 @@ internal class SwapModel @Inject constructor(
swapInteractor.getPair(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) {
listOf(ExchangeProviderType.CEX)
} else {
ExchangeProviderType.getSwapProviderTypes()
},
filterProviderTypes = ExchangeProviderType.getSwapProviderTypes(),
).fold(
ifLeft = { error ->
uiState = stateBuilder.createInitialErrorState(
@ -686,7 +692,11 @@ internal class SwapModel @Inject constructor(
)
TangemLogger.e("Error getting swap pair", error)
},
ifRight = { pairs ->
ifRight = { pairsRaw ->
val pairs = pairsRaw.filterTangemPayProviders(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
val providerList = swapInteractor.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -1266,7 +1276,7 @@ internal class SwapModel @Inject constructor(
val isTangemPayWithdrawal = isTangemPayWithdrawal()
if (swapFee == null && !isTangemPayWithdrawal) {
TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}")
TangemLogger.e("onSwapClick: fee is null and isTangemPayWithdrawal is $isTangemPayWithdrawal")
showAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
modelScope.launch {
delay(SWAP_IN_PROGRESS_DELAY)
@ -2220,8 +2230,31 @@ internal class SwapModel @Inject constructor(
)
}
fun isTangemPayWithdrawal(): Boolean {
return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment
fun isTangemPayWithdrawal(fromSwapCurrencyStatus: SwapCurrencyStatus? = dataState.fromSwapCurrencyStatus): Boolean {
return fromSwapCurrencyStatus?.account is Account.Payment
}
private fun List<SwapPairLeast>.filterTangemPayProviders(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
) = map { pair ->
val isTangemPayWithdrawal = isTangemPayWithdrawal(
swapInteractor.extractFromSwapCurrencyFromPair(
pair = pair,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
),
)
val filterProviderTypes = if (isTangemPayWithdrawal) {
listOf(ExchangeProviderType.CEX)
} else {
emptyList()
}
pair.copy(
providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider ->
provider.type in filterProviderTypes
},
)
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
@ -2405,6 +2438,28 @@ internal class SwapModel @Inject constructor(
override val forceUpdateState = MutableSharedFlow<FeeSelectorUM>()
/**
* Resolves the `swapData` to hand to [SwapInteractor.loadSwapFee] for the native (non-gasless)
* fee load. A DEX/DEX_BRIDGE provider whose quote returned `txType=SEND` (swap-xyz native
* transfer) re-routes to the CEX-style flow without DEX swapData returns `null`. A real DEX
* quote without resolved swapData is an error.
*/
private fun resolveDexSwapDataForFee(
quoteState: SwapState.QuotesLoadedState,
): Either<GetFeeError, SwapDataModel?> {
return when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
if (quoteState.txType == ExpressTxType.SEND) {
Either.Right(null)
} else {
quoteState.swapDataModel?.let { Either.Right(it) }
?: Either.Left(GetFeeError.UnknownError)
}
}
ExchangeProviderType.CEX -> Either.Right(null)
}
}
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val fromSwapCurrencyStatus =
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
@ -2447,17 +2502,15 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError)
}
ExchangeProviderType.CEX -> null
}
val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull()
?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapDataForCall = resolveDexSwapDataForFee(quoteState)
.getOrElse { return Either.Left(it) }
val integratedSettings = (quoteState.permissionState as? PermissionDataState.PermissionSettings)
?.takeIf { swapFeatureToggles.isSwapIntegratedApproveEnabled }
// Get swap tx fee
return swapInteractor.loadSwapFee(
quotesLoadedState = quoteState,
fromStatus = fromSwapCurrencyStatus,
@ -2466,6 +2519,7 @@ internal class SwapModel @Inject constructor(
swapData = swapDataForCall,
selectedFeeToken = null,
isGasless = false,
txType = quoteState.txType,
).map { swapFee ->
when (val res = swapFee.transactionFeeResult) {
is TransactionFeeResult.LoadedExtended -> res.fee.transactionFee
@ -2518,11 +2572,16 @@ internal class SwapModel @Inject constructor(
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
// DEX path requires a SwapDataModel and does not support gasless yet. swap-xyz native
// transfers (txType=SEND) re-route to the CEX-style flow, so they take the CEX fee path.
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
// TODO support gasless in DEX/DEX_BRIDGE
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
if (quoteState.txType == ExpressTxType.SEND) {
null
} else {
// TODO support gasless in DEX/DEX_BRIDGE
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
}
}
ExchangeProviderType.CEX -> null
}
@ -2535,6 +2594,7 @@ internal class SwapModel @Inject constructor(
swapData = swapDataForCall,
selectedFeeToken = selectedToken,
isGasless = true,
txType = quoteState.txType,
).map { swapFee ->
// The fee selector block consumes TransactionFeeExtended; build one when
// `transactionFeeResult` is LoadedExtended, else wrap the native fee in a

View file

@ -59,7 +59,7 @@ sealed class SwapCardState {
val currencyIconState: CurrencyIconState,
val tokenSymbol: TextReference,
val amountEquivalent: TextReference?,
val balance: String,
val balance: TextReference,
val isBalanceHidden: Boolean,
val appCurrency: AppCurrency,
val amountField: AmountFieldModel? = null,

View file

@ -76,12 +76,12 @@ internal object SwapNotificationUM {
),
subtitle = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_description,
wrappedList(currencyName, currencySymbol),
wrappedList(feeCurrency.name, feeCurrency.symbol),
),
iconResId = fromToken.networkIconResId,
buttonState = onConfirmClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
text = resourceReference(R.string.common_buy_currency, wrappedList(feeCurrency.symbol)),
onClick = onConfirmClick,
)
},

View file

@ -307,7 +307,7 @@ internal class StateBuilder(
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
@ -320,7 +320,7 @@ internal class StateBuilder(
copy(
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
@ -410,7 +410,7 @@ internal class StateBuilder(
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = if (isFromCard) {
emptyAmountField(swapCurrencyStatus)
@ -451,7 +451,7 @@ internal class StateBuilder(
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
@ -463,7 +463,7 @@ internal class StateBuilder(
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
@ -579,7 +579,7 @@ internal class StateBuilder(
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = uiStateHolder.sendCardData.amountField,
appCurrency = appCurrencyProvider(),
@ -618,7 +618,7 @@ internal class StateBuilder(
},
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
@ -804,7 +804,7 @@ internal class StateBuilder(
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toToken.getFormattedAmount(isNeedSymbol = false),
balance = toToken.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
)
@ -1326,10 +1326,12 @@ internal class StateBuilder(
}
}
private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String {
val amount = this?.value?.amount ?: return DASH_SIGN
val symbol = if (isNeedSymbol) currency.symbol else ""
return amount.format { crypto(symbol, currency.decimals) }
private fun CryptoCurrencyStatus?.getFormattedAmount(): TextReference {
if (this == null) return stringReference(DASH_SIGN)
return resourceReference(
R.string.common_balance,
wrappedList(this.value.amount.format { crypto(currency.symbol, currency.decimals) }),
)
}
private fun getFormattedFiatAmount(amount: BigDecimal?): TextReference {

View file

@ -111,10 +111,8 @@ private fun TransactionCardData(
horizontalAlignment = Alignment.Start,
) {
Header(
balance = stringResourceSafe(
R.string.common_balance,
cardState.balance,
).orMaskWithStars(cardState.isBalanceHidden),
balance = cardState.balance,
isBalanceHidden = cardState.isBalanceHidden,
type = cardState.type,
)
@ -283,7 +281,12 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) {
}
@Composable
private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
private fun Header(
type: TransactionCardType,
balance: TextReference,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
@ -305,13 +308,13 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
textColor = titleColor,
)
SpacerW16()
if (balance.isNotBlank()) {
if (balance != TextReference.EMPTY) {
AnimatedContent(
targetState = balance,
label = "",
) { balanceText ->
Text(
text = balanceText,
text = balanceText.resolveReference().orMaskWithStars(isBalanceHidden),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE),

View file

@ -32,10 +32,7 @@ import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
@ -93,10 +90,8 @@ private fun SimpleTransactionCardData(
horizontalAlignment = Alignment.Start,
) {
SimpleHeader(
balance = stringResourceSafe(
R.string.common_balance,
cardState.balance,
).orMaskWithStars(cardState.isBalanceHidden),
balance = cardState.balance,
isBalanceHidden = cardState.isBalanceHidden,
type = cardState.type,
)
@ -260,7 +255,12 @@ private fun SimpleTransactionCardLoading(modifier: Modifier = Modifier) {
}
@Composable
private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
private fun SimpleHeader(
type: TransactionCardType,
balance: TextReference,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
@ -282,10 +282,10 @@ private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: M
textColor = titleColor,
)
SpacerW16()
if (balance.isNotBlank()) {
if (balance != TextReference.EMPTY) {
AnimatedContent(targetState = balance, label = "") { balanceText ->
Text(
text = balanceText,
text = balanceText.resolveReference().orMaskWithStars(isBalanceHidden),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE),

View file

@ -37,7 +37,7 @@ internal object SwapTransactionCardPreview {
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "123123123.123123",
balance = stringReference("Balance: 123123123.123123 DAI"),
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(
@ -74,7 +74,7 @@ internal object SwapTransactionCardPreview {
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "33333",
balance = stringReference("Balance: 33333 DAI"),
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(

View file

@ -37,7 +37,6 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
@ -286,9 +285,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
}
private fun CryptoCurrencyStatus.getFormattedAmount(): String {
val amount = this.value.amount ?: return DASH_SIGN
return amount.format { crypto(symbol = "", decimals = currency.decimals) }
private fun CryptoCurrencyStatus.getFormattedAmount(): TextReference {
return resourceReference(
R.string.common_balance,
wrappedList(value.amount.format { crypto(currency.symbol, currency.decimals) }),
)
}
private fun Account.toIconUM(): AccountIconUM {

View file

@ -782,7 +782,7 @@ internal class SwapTransferStateBuilderTest {
tokenSymbol = stringReference(""),
amountEquivalent = TextReference.EMPTY,
amountField = initialAmountField,
balance = "",
balance = TextReference.EMPTY,
isBalanceHidden = false,
),
receiveCardData = SwapCardState.Loading(

View file

@ -482,7 +482,6 @@ internal class TangemPayCardPageModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)

View file

@ -225,7 +225,6 @@ internal class TangemPayDetailsModel @Inject constructor(
cryptoAmount = balance.availableForWithdrawal,
fiatAmount = balance.availableForWithdrawal,
depositAddress = balance.cryptoBalance.depositAddress,
isWithdrawal = true,
),
),
)
@ -317,7 +316,6 @@ internal class TangemPayDetailsModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)

View file

@ -114,13 +114,13 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
if (LocalRedesignEnabled.current) {
val tokenDetailsUM by model.redesignUiState.collectAsStateWithLifecycle()
// TODO [REDACTED_TASK_KEY]: wire ratingSlotState into TokenDetailsScreen when redesign is ready
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
yieldSupplyComponent = yieldSupplyComponent,
txHistoryComponent = txHistoryComponent,
expressTransactionsComponent = expressTransactionsComponent,
ratingComponent = ratingSlotState.child?.instance,
modifier = modifier,
)
} else {

View file

@ -1,10 +1,12 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.ResettableOneTimeEventSender
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
@ -55,6 +57,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
@Assisted private val userWallet: UserWallet,
@Assisted private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@ -77,7 +80,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
// region Entry point
fun onDynamicAddressesClick() {
fun openBottomSheet() {
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency))
coroutineScope.launch(dispatchers.main) {
@ -313,7 +316,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onReadMoreClick() {
// TODO: Replace with actual URL
coroutineScope.launch(dispatchers.main) {
urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee))
}
}
private fun onDisableClick() {

View file

@ -113,14 +113,13 @@ internal class ExpressTransactionsModel @Inject constructor(
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
if (expressTxState is ExchangeUM) {
expressTxState.info.txExternalId?.let { txExternalId ->
params.onRatingRequested?.invoke(
txExternalId,
expressTxState.provider.name,
expressTxState.info.txExternalUrl.orEmpty(),
expressTxState.fromUserWalletId.stringValue,
)
}
val ratingTxId = expressTxState.info.txExternalId ?: expressTxState.info.txId
params.onRatingRequested?.invoke(
ratingTxId,
expressTxState.provider.name,
expressTxState.info.txExternalUrl.orEmpty(),
expressTxState.fromUserWalletId.stringValue,
)
}
}

View file

@ -69,6 +69,7 @@ import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
@ -732,11 +733,9 @@ internal class TokenDetailsModel @Inject constructor(
openStaking()
}
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.openBottomSheet()
override fun onDynamicAddressesFundsFoundLearnMoreClick() {
// TODO: open "Learn more" URL once the destination is decided
}
override fun onDynamicAddressesFundsFoundLearnMoreClick() = dynamicAddressesDelegate.openBottomSheet()
private fun onDynamicAddressesStateChanged() {
updateTopBarMenu()
@ -1204,7 +1203,7 @@ internal class TokenDetailsModel @Inject constructor(
modelScope.launch {
getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency)
.onRight { availability ->
val option = (availability as? StakingAvailability.Available)?.option
val option = availability.optionOrNull
if (option != null) {
router.openStaking(
userWalletId = userWalletId,

View file

@ -52,6 +52,7 @@ internal class TokenDetailsStakingInfoConverter(
return when (stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable
StakingAvailability.Unavailable -> null
is StakingAvailability.Full -> getStakedBlockOrNull(status)
is StakingAvailability.Available -> getStakingInfoBlock(status, state)
}
}
@ -107,6 +108,39 @@ internal class TokenDetailsStakingInfoConverter(
}
}
private fun getStakedBlockOrNull(status: CryptoCurrencyStatus): StakingBlockUM? {
val stakingBalance = status.value.stakingBalance as? StakingBalance.Data
val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId)
val hasPendingBalances = when (stakingBalance) {
is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.isNotEmpty()
is StakingBalance.Data.P2PEthPool -> !stakingBalance.unstakingAmount.isNullOrZero()
null -> false
}
return when {
!stakingCryptoAmount.isNullOrZero() -> getStakedBlockWithFiatAmount(
status = status,
stakingAmount = stakingCryptoAmount,
rewardAmount = when (stakingBalance) {
is StakingBalance.Data.StakeKit -> stakingBalance.getRewardStakingBalance()
is StakingBalance.Data.P2PEthPool -> stakingBalance.totalRewards
else -> BigDecimal.ZERO
},
)
// Pending-only path: reachable for StakeKit (pending items are not part of the total);
// for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above.
hasPendingBalances -> getStakedBlockWithFiatAmount(
status = status,
stakingAmount = when (stakingBalance) {
is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.sumOf { it.amount }
is StakingBalance.Data.P2PEthPool -> stakingBalance.unstakingAmount
null -> BigDecimal.ZERO
},
rewardAmount = null,
)
else -> null
}
}
private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean {
return status.value is CryptoCurrencyStatus.Loaded ||
status.value is CryptoCurrencyStatus.NoQuote ||

View file

@ -47,6 +47,7 @@ internal class UpdateStakingNotificationTransformer(
return when (val availability = stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable()
StakingAvailability.Unavailable -> null
is StakingAvailability.Full -> buildActiveBlockOrNull(isBalanceHidden)
is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden)
}
}
@ -103,6 +104,27 @@ internal class UpdateStakingNotificationTransformer(
}
}
private fun buildActiveBlockOrNull(isBalanceHidden: Boolean): EarnBlockUM? {
val status = cryptoCurrencyStatus
val stakingBalance = status.value.stakingBalance as? StakingBalance.Data
val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId)
return when {
!stakingCryptoAmount.isNullOrZero() -> buildActiveBlock(
stakingAmount = stakingCryptoAmount,
rewardAmount = stakingBalance.getRewardAmount(),
isBalanceHidden = isBalanceHidden,
)
// Pending-only path: reachable for StakeKit (pending items are not part of the total);
// for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above.
stakingBalance.hasPendingBalances() -> buildActiveBlock(
stakingAmount = stakingBalance.getPendingAmount(),
rewardAmount = null,
isBalanceHidden = isBalanceHidden,
)
else -> null
}
}
private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean {
return status.value is CryptoCurrencyStatus.Loaded ||
status.value is CryptoCurrencyStatus.NoQuote ||

View file

@ -46,6 +46,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.rating.RatingComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
@ -63,6 +64,7 @@ private val MarketBlockHorizontalPadding: Dp = 14.dp
private const val TOP_FADE_MID_STOP = 0.8f
private const val TOP_FADE_MID_ALPHA = 0.8f
@Suppress("LongParameterList")
@Composable
internal fun TokenDetailsScreen(
tokenDetailsUM: TokenDetailsUM,
@ -70,6 +72,7 @@ internal fun TokenDetailsScreen(
yieldSupplyComponent: YieldSupplyComponent,
txHistoryComponent: TxHistoryComponent,
expressTransactionsComponent: ExpressTransactionsComponent,
ratingComponent: RatingComponent?,
modifier: Modifier = Modifier,
) {
val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle()
@ -112,7 +115,10 @@ internal fun TokenDetailsScreen(
onHeightChange = { marketBlockHeight = it },
)
}
expressState.bottomSheetSlot?.content(null)
expressState.bottomSheetSlot?.content(
ratingComponent?.let { comp -> { comp.Content(modifier = Modifier.fillMaxWidth()) } },
)
}
}
}
@ -310,6 +316,7 @@ private fun TokenDetailsScreen_Preview() {
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit
},
expressTransactionsComponent = PreviewExpressTransactionsComponent,
ratingComponent = null,
)
}
}

View file

@ -4,10 +4,12 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.IsDynamicAddressesConsolidationRequiredUseCase
@ -35,7 +37,9 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -50,6 +54,7 @@ private const val TEST_XPUB = "xpub-test-value"
private const val TOKEN_SYMBOL = "ETH"
private const val BLOCKCHAIN_NAME = "Ethereum"
private const val TEST_ADDRESS = "0xTestAddress"
private const val TEST_BLOG_URL = "https://tangem.com/embed/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it"
@OptIn(ExperimentalCoroutinesApi::class)
internal class DynamicAddressesDelegateTest {
@ -65,6 +70,7 @@ internal class DynamicAddressesDelegateTest {
private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true)
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { name } returns BLOCKCHAIN_NAME
@ -87,7 +93,7 @@ internal class DynamicAddressesDelegateTest {
private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() =
fun `GIVEN currency is available WHEN openBottomSheet THEN DynamicAddressesScreenOpened event is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
@ -99,7 +105,7 @@ internal class DynamicAddressesDelegateTest {
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened
@ -110,19 +116,19 @@ internal class DynamicAddressesDelegateTest {
}
@Test
fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest {
fun `GIVEN no currency WHEN openBottomSheet THEN no event is sent`() = runTest {
// GIVEN
val delegate = createDelegate(cryptoCurrencyStatus = null)
// WHEN
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() =
fun `GIVEN DISABLED status AND conflicts WHEN openBottomSheet THEN Notice DynamicAddressesUnavailable is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
@ -131,7 +137,7 @@ internal class DynamicAddressesDelegateTest {
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// THEN
verify {
@ -157,7 +163,7 @@ internal class DynamicAddressesDelegateTest {
coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
@ -192,7 +198,7 @@ internal class DynamicAddressesDelegateTest {
IllegalStateException("xpub fail").left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
@ -220,7 +226,7 @@ internal class DynamicAddressesDelegateTest {
TangemSdkError.UserCancelled().left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
@ -244,7 +250,7 @@ internal class DynamicAddressesDelegateTest {
EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
@ -268,7 +274,7 @@ internal class DynamicAddressesDelegateTest {
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation)
@ -289,6 +295,31 @@ internal class DynamicAddressesDelegateTest {
}
}
@Test
fun `GIVEN disable sheet WHEN read more clicked THEN transaction fee article is opened`() = runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.ENABLED)
coEvery { isConsolidationRequiredUseCase(userWalletId, network) } returns false.right()
mockkObject(TangemBlogUrlBuilder)
try {
coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee) } returns TEST_BLOG_URL
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation)
.onReadMoreClick()
// THEN
verify { urlOpener.openUrl(TEST_BLOG_URL) }
} finally {
unmockkObject(TangemBlogUrlBuilder)
}
}
@Test
fun `GIVEN ENABLED status AND no consolidation WHEN menu tapped without confirmation THEN repository disable is NOT called`() =
runTest {
@ -297,7 +328,7 @@ internal class DynamicAddressesDelegateTest {
coEvery { isConsolidationRequiredUseCase(userWalletId, network) } returns false.right()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// The simple disable sheet must be shown but no backend write must happen yet.
assertThat(delegate.bottomSheetConfig.value)
@ -318,7 +349,7 @@ internal class DynamicAddressesDelegateTest {
// WHEN
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// THEN
val notEnoughFee = events
@ -341,7 +372,7 @@ internal class DynamicAddressesDelegateTest {
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN: initial load + refresh
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onRefreshFee()
@ -365,7 +396,7 @@ internal class DynamicAddressesDelegateTest {
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
@ -393,7 +424,7 @@ internal class DynamicAddressesDelegateTest {
SendTransactionError.UserCancelledError.left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
delegate.openBottomSheet()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
@ -444,6 +475,7 @@ internal class DynamicAddressesDelegateTest {
getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
urlOpener = urlOpener,
dispatchers = TestingCoroutineDispatcherProvider(),
userWallet = userWallet,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },

View file

@ -92,6 +92,33 @@ class UpdateStakingNotificationTransformerTest {
assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
}
@Test
fun `GIVEN Full AND no active stake WHEN transform THEN earnBlockState is null`() {
val transformer = createTransformer(
availability = fullOption(BigDecimal("4.2")),
entryInfo = null,
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isNull()
}
@Test
fun `GIVEN Full AND active stake WHEN transform THEN active balance block`() {
val transformer = createTransformer(
availability = fullOption(BigDecimal("4.2")),
entryInfo = null,
status = buildStatusWithStake(stakedAmount = BigDecimal("5")),
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java)
val content = result.earnBlockState as EarnBlockUM.Content
assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Balance::class.java)
}
@Test
fun `GIVEN active staked balance AND balance hidden WHEN transform THEN trailing balance hidden`() {
// Arrange
@ -104,7 +131,7 @@ class UpdateStakingNotificationTransformerTest {
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
status = status,
isBalanceHidden = true,
)
@ -129,7 +156,7 @@ class UpdateStakingNotificationTransformerTest {
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
status = status,
isBalanceHidden = true,
)
@ -154,7 +181,7 @@ class UpdateStakingNotificationTransformerTest {
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
status = status,
isBalanceHidden = false,
)
@ -173,10 +200,10 @@ class UpdateStakingNotificationTransformerTest {
private fun createTransformer(
availability: StakingAvailability,
entryInfo: StakingEntryInfo?,
cryptoCurrencyStatus: CryptoCurrencyStatus = buildStatus(),
status: CryptoCurrencyStatus = buildStatus(),
isBalanceHidden: Boolean = false,
) = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = status,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = AppCurrency.Default,
@ -244,6 +271,38 @@ class UpdateStakingNotificationTransformerTest {
return StakingAvailability.Available(option = option)
}
private fun fullOption(apy: BigDecimal): StakingAvailability.Full {
val option = mockk<StakingOption>(relaxed = true) {
every { this@mockk.apy } returns apy
}
return StakingAvailability.Full(option = option)
}
private fun buildStatusWithStake(stakedAmount: BigDecimal): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns "solana"
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "SOL"
every { decimals } returns 9
every { this@mockk.network } returns network
every { id.isCoin } returns true
}
val stakingBalance = mockk<StakingBalance.Data.P2PEthPool>(relaxed = true) {
every { totalStaked } returns stakedAmount
every { unstakingAmount } returns BigDecimal.ZERO
every { withdrawableAmount } returns BigDecimal.ZERO
every { totalRewards } returns BigDecimal.ZERO
}
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
every { yieldSupplyStatus } returns null
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun initialState(isBalanceHidden: Boolean = false): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Solana"),

View file

@ -64,4 +64,5 @@ dependencies {
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -144,7 +144,13 @@ internal class TxHistoryModel @Inject constructor(
private fun subscribeToUiItemChanges() {
txHistoryListManager.uiItems
.onEach { snapshot -> stateController.setContent(snapshot = snapshot, loadMore = ::loadMoreItems) }
.onEach { snapshot ->
stateController.setContent(
snapshot = snapshot,
loadMore = ::loadMoreItems,
onExploreClick = ::openExplorer,
)
}
.launchIn(modelScope)
txHistoryListManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }

View file

@ -110,10 +110,15 @@ internal class TxHistoryStateController @Inject constructor(
}
}
fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean) {
fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean, onExploreClick: () -> Unit) {
when (snapshot) {
is TxHistoryItemsSnapshot.Items -> _uiState.update { state ->
if (state is TxHistoryItemsUM.Content) {
if (snapshot.items.none { it is TxHistoryItemsUM.TxHistoryItemUM.Transaction }) {
TxHistoryItemsUM.Empty(
isBalanceHidden = state.isBalanceHidden,
onExploreClick = onExploreClick,
)
} else if (state is TxHistoryItemsUM.Content) {
state.copy(items = snapshot.items)
} else {
TxHistoryItemsUM.Content(
@ -125,7 +130,12 @@ internal class TxHistoryStateController @Inject constructor(
}
}
is TxHistoryItemsSnapshot.LegacyItems -> _legacyUiState.update { state ->
if (state is TxHistoryUM.Content) {
if (snapshot.items.none { it is TxHistoryUM.TxHistoryItemUM.Transaction }) {
TxHistoryUM.Empty(
isBalanceHidden = state.isBalanceHidden,
onExploreClick = onExploreClick,
)
} else if (state is TxHistoryUM.Content) {
state.copy(items = snapshot.items)
} else {
TxHistoryUM.Content(

View file

@ -13,6 +13,7 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateCo
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -37,6 +38,7 @@ internal class TxHistoryListManager(
) {
private val jobHolder = JobHolder()
private val autoLoadMoreJobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<TxHistoryBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -65,6 +67,12 @@ internal class TxHistoryListManager(
batchSize = 50,
)
batchFlow.state
.onEach { batchState -> autoLoadMoreUntilScrollable(batchState) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(autoLoadMoreJobHolder)
if (designFeatureToggles.isRedesignEnabled) {
var previousLookup: TxHistoryLookupContext? = null
combine(batchFlow.state, lookupDataFlow) { batchState, lookup -> batchState to lookup }
@ -157,4 +165,19 @@ internal class TxHistoryListManager(
)
}
}
private suspend fun autoLoadMoreUntilScrollable(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
val status = batchState.status as? PaginationStatus.Paginating ?: return
val lastResult = status.lastResult as? BatchFetchResult.Success ?: return
val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size }
val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty
if (shouldLoadMore) {
loadMore(userWalletId, currency)
}
}
private companion object {
/** Number of loaded items considered enough to make the list scrollable. */
const val AUTO_LOAD_MORE_TARGET_COUNT = 20
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.features.txhistory.state
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryStateControllerTest {
private val controller = TxHistoryStateController(
designFeatureToggles = mockk { every { isRedesignEnabled } returns true },
)
private val legacyController = TxHistoryStateController(
designFeatureToggles = mockk { every { isRedesignEnabled } returns false },
)
@Test
fun `GIVEN empty items snapshot WHEN setContent THEN Empty state with explorer action`() {
val onExploreClick = {}
controller.setContent(
snapshot = TxHistoryItemsSnapshot.Items(persistentListOf()),
loadMore = { true },
onExploreClick = onExploreClick,
)
val state = controller.uiState.value
assertThat(state).isInstanceOf(TxHistoryItemsUM.Empty::class.java)
assertThat((state as TxHistoryItemsUM.Empty).onExploreClick).isEqualTo(onExploreClick)
}
@Test
fun `GIVEN snapshot with only a group title WHEN setContent THEN Empty state`() {
controller.setContent(
snapshot = TxHistoryItemsSnapshot.Items(
persistentListOf(
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "0-Today"),
),
),
loadMore = { true },
onExploreClick = {},
)
assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Empty::class.java)
}
@Test
fun `GIVEN snapshot with transactions WHEN setContent THEN Content state`() {
controller.setContent(
snapshot = TxHistoryItemsSnapshot.Items(
persistentListOf(
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "0-Today"),
TxHistoryItemsUM.TxHistoryItemUM.Transaction(TransactionItemUM.Loading("hash")),
),
),
loadMore = { true },
onExploreClick = {},
)
assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Content::class.java)
}
@Test
fun `GIVEN Empty state WHEN empty snapshot arrives THEN Empty is not overridden by Content`() {
controller.setEmpty(onExploreClick = {})
controller.setContent(
snapshot = TxHistoryItemsSnapshot.Items(persistentListOf()),
loadMore = { true },
onExploreClick = {},
)
assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Empty::class.java)
}
// region Legacy (e.g. Solana: probe reports HasTransactions but the mapped page is empty)
@Test
fun `GIVEN legacy snapshot with only a title WHEN setContent THEN legacy Empty state with explorer`() {
val onExploreClick = {}
legacyController.setContent(
snapshot = TxHistoryItemsSnapshot.LegacyItems(
persistentListOf(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {})),
),
loadMore = { true },
onExploreClick = onExploreClick,
)
val state = legacyController.legacyUiState.value
assertThat(state).isInstanceOf(TxHistoryUM.Empty::class.java)
assertThat((state as TxHistoryUM.Empty).onExploreClick).isEqualTo(onExploreClick)
}
@Test
fun `GIVEN legacy snapshot with transactions WHEN setContent THEN legacy Content state`() {
legacyController.setContent(
snapshot = TxHistoryItemsSnapshot.LegacyItems(
persistentListOf(
TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {}),
TxHistoryUM.TxHistoryItemUM.Transaction(TransactionState.Loading("hash")),
),
),
loadMore = { true },
onExploreClick = {},
)
assertThat(legacyController.legacyUiState.value).isInstanceOf(TxHistoryUM.Content::class.java)
}
// endregion
}

View file

@ -0,0 +1,231 @@
package com.tangem.features.txhistory.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
import com.tangem.domain.txhistory.models.Page
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.PaginationStatus
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Verifies the auto-load behavior for Solana-style histories, where a fetched page is paginated over RAW
* transactions and then filtered down to a single token, so a page can yield few or zero displayable items.
* The manager must keep requesting the next page until the list is long enough to be scrolled or pagination
* ends instead of stopping on the first page that adds no items.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryListManagerTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
@Test
fun `GIVEN pages that are empty for the token WHEN loading THEN auto-loads through them until the end`() =
runTest {
// page 0: 2 items, then two empty-for-token pages, then 3 items on the last page → 5 items total.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 2, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 3, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first fetch + 3 auto-loaded next pages = 4
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(5)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
@Test
fun `GIVEN many small non-final pages WHEN loading THEN stops once the list is long enough to scroll`() =
runTest {
// every page returns 7 items and is never the last page.
val fetcher = ScriptedFetcher { page(itemCount = 7, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// 7 -> 14 -> 21: stops after crossing AUTO_LOAD_MORE_TARGET_COUNT (20), does not keep loading.
assertThat(fetcher.fetchCount).isEqualTo(3)
assertThat(repo.loadedItemsCount()).isEqualTo(21)
assertThat(repo.status()).isInstanceOf(PaginationStatus.Paginating::class.java)
}
}
@Test
fun `GIVEN a full first page WHEN loading THEN does not auto-load more`() = runTest {
val fetcher = ScriptedFetcher { page(itemCount = 25, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first page already exceeds the target → no auto-load, behaves like a normal scroll-driven list.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
}
}
@Test
fun `GIVEN a gap of empty pages mid-history WHEN scrolled to the end THEN auto-loads through the gap`() =
runTest {
// A full first page (no auto-load), then two empty-for-token pages (a gap of other-token
// activity), then one final item. Mirrors a busy account where a token has a long activity gap.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 25, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 1, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// full first page → no auto-load yet, the list is scrollable.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
// user scrolls to the bottom → one loadMore; the empty gap must be auto-bridged to the end,
// otherwise the list dead-ends and the final transaction is never reached.
manager.loadMore(userWalletId, currency)
advanceUntilIdle()
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(26)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
private suspend fun TestScope.withLoadedManager(
manager: TxHistoryListManager,
assertions: suspend TestScope.() -> Unit,
) {
// init() collects forever, so run it in a child coroutine and cancel it once assertions are done.
// Cancellation resets the source state, so assertions must run before it.
val initJob = launch { manager.init() }
advanceUntilIdle()
manager.startLoading()
advanceUntilIdle()
try {
assertions()
} finally {
initJob.cancel()
}
}
private fun TestScope.fakeRepository(
fetcher: BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>>,
): FakeRepository = FakeRepository(testDispatchers(StandardTestDispatcher(testScheduler)), fetcher)
private fun createManager(repository: FakeRepository): TxHistoryListManager = TxHistoryListManager(
repository = repository,
dispatchers = repository.dispatchers,
userWalletId = userWalletId,
currency = currency,
designFeatureToggles = mockk { every { isRedesignEnabled } returns false },
txHistoryUiActions = mockk(relaxed = true),
lookupDataFlow = emptyFlow(),
legacyTxHistoryItemConverter = mockk<TxHistoryItemToTransactionStateConverter>(relaxed = true),
)
private fun page(itemCount: Int, isLast: Boolean): Page2Spec =
Page2Spec(itemCount = itemCount, isLast = isLast)
private fun testDispatchers(dispatcher: CoroutineDispatcher): CoroutineDispatcherProvider =
object : CoroutineDispatcherProvider {
override val main: CoroutineDispatcher = dispatcher
override val mainImmediate: CoroutineDispatcher = dispatcher
override val io: CoroutineDispatcher = dispatcher
override val default: CoroutineDispatcher = dispatcher
override val single: CoroutineDispatcher = dispatcher
}
/** Page description. The fetcher turns it into a wrapper with a unique cursor, mirroring real pagination. */
private data class Page2Spec(val itemCount: Int, val isLast: Boolean)
private class ScriptedFetcher(
private val pageAt: (call: Int) -> Page2Spec,
) : BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>> {
var fetchCount = 0
private set
override suspend fun fetchFirst(requestParams: TxHistoryListConfig) = produce()
override suspend fun fetchNext(
overrideRequestParams: TxHistoryListConfig?,
lastResult: BatchFetchResult<PaginationWrapper<TxInfo>>,
) = produce()
private fun produce(): BatchFetchResult<PaginationWrapper<TxInfo>> {
val spec = pageAt(fetchCount)
// A unique cursor per fetch mirrors real pagination (each page has its own paginationToken) and
// prevents StateFlow from conflating two otherwise-identical empty pages.
val wrapper = PaginationWrapper(
currentPage = if (fetchCount == 0) Page.Initial else Page.Next(value = "cursor-$fetchCount"),
nextPage = if (spec.isLast) Page.LastPage else Page.Next(value = "cursor-${fetchCount + 1}"),
items = List(spec.itemCount) { mockk<TxInfo>(relaxed = true) },
)
fetchCount++
return BatchFetchResult.Success(
data = wrapper,
empty = wrapper.items.isEmpty(),
last = spec.isLast,
)
}
}
private class FakeRepository(
val dispatchers: CoroutineDispatcherProvider,
private val fetcher: BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>>,
) : TxHistoryRepositoryV2 {
private lateinit var batchFlow: TxHistoryListBatchFlow
override fun getTxHistoryBatchFlow(
batchSize: Int,
context: TxHistoryListBatchingContext,
): TxHistoryListBatchFlow = BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = fetcher,
).toBatchFlow().also { batchFlow = it }
fun loadedItemsCount(): Int = batchFlow.state.value.data.sumOf { batch -> batch.data.items.size }
fun status(): PaginationStatus<*> = batchFlow.state.value.status
}
}

View file

@ -378,7 +378,7 @@ internal enum class Wallet2CobrandImage(
ElectraSea(
cards2ResId = R.drawable.ill_electra_sea_card2_120_106,
cards3ResId = R.drawable.ill_electra_sea_card3_120_106,
batchIds = setOf("AF990023", "AF990024", "AF990025"),
batchIds = setOf("AF990023", "AF990024", "AF990025", "AF990067", "AF990066", "AF990065"),
),
Football(

View file

@ -12,6 +12,7 @@ import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.lib.crypto.BlockchainUtils
@ -77,14 +78,21 @@ internal class EarnApyConverter(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available
val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
val isActive = stakeKitBalance != null || p2pEthPoolBalance != null
val rateInfo = when (val stakingOptions = stakingAvailability.option) {
// Full = no free capacity: show the badge only for tokens that already have a stake.
if (availability is StakingAvailability.Full && !isActive) {
return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
}
val rateInfo = when (val stakingOptions = option) {
is StakingOption.P2PEthPool -> {
RewardInfo(
rate = stakingOptions.apy,
@ -115,7 +123,7 @@ internal class EarnApyConverter(
return StakingLocalInfo(
rate = rateInfo?.rate,
isActive = stakeKitBalance != null || p2pEthPoolBalance != null,
isActive = isActive,
rewardType = rateInfo?.type,
)
}

View file

@ -5,6 +5,7 @@ import arrow.core.Either
import arrow.core.Option
import arrow.core.none
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.navigate
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.domain.blockaid.models.dapp.CheckDAppResult
@ -13,6 +14,7 @@ import com.domain.blockaid.models.transaction.ValidationResult
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -85,6 +87,7 @@ internal class WcSendTransactionModel @Inject constructor(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val notificationsFactory: WcNotificationsFactory,
private val analytics: AnalyticsEventHandler,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val urlOpener: UrlOpener,
) : Model(), WcCommonTransactionModel, FeeSelectorModelCallback {
@ -187,12 +190,18 @@ internal class WcSendTransactionModel @Inject constructor(
}
private fun openMultipleTransaction() {
stackNavigation.pushNew(WcTransactionRoutes.MultipleTransactions)
stackNavigation.navigate { listOf(WcTransactionRoutes.Transaction, WcTransactionRoutes.MultipleTransactions) }
}
fun onMultiTransactionConfirm() {
useCase.sign()
stackNavigation.pushNew(WcTransactionRoutes.TransactionProcess)
stackNavigation.navigate {
listOf(
WcTransactionRoutes.Transaction,
WcTransactionRoutes.MultipleTransactions,
WcTransactionRoutes.TransactionProcess,
)
}
}
/**
@ -421,6 +430,11 @@ internal class WcSendTransactionModel @Inject constructor(
onDismiss = { cancel(useCase) },
onRetry = { signFromAlert() },
)
if (useCase is WcListTransactionUseCase) {
analyticsErrorHandler.sendErrorEvent(
event = WcAnalyticEvents.WcSolanaMultiTxFailure(rawRequest = useCase.rawSdkRequest),
)
}
stackNavigation.pushNew(WcTransactionRoutes.Alert(alertError))
false
}

View file

@ -1,6 +1,10 @@
package com.tangem.features.yield.supply.impl.active.model
import kotlinx.datetime.Instant
import kotlin.time.Duration.Companion.days
/** How long the awaiting-payout copy stays visible after the qualification period ends, before the block is hidden. */
private val AWAITING_PAYOUT_WINDOW = 14.days
/** What the boost block on the active screen should display, derived solely from the qualification end date. */
internal sealed interface BoostBlockState {
@ -11,12 +15,13 @@ internal sealed interface BoostBlockState {
/** Qualification period is over — show the awaiting-payout copy. */
data object AwaitingPayout : BoostBlockState
/** No qualification end date — show nothing. */
/** No qualification end date, or the awaiting-payout window has elapsed — show nothing. */
data object Hidden : BoostBlockState
}
internal fun resolveBoostBlockState(qualificationEndDate: Instant?, now: Instant): BoostBlockState = when {
qualificationEndDate == null -> BoostBlockState.Hidden
now >= qualificationEndDate + AWAITING_PAYOUT_WINDOW -> BoostBlockState.Hidden
now >= qualificationEndDate -> BoostBlockState.AwaitingPayout
else -> BoostBlockState.DaysLeft(days = (qualificationEndDate - now).inWholeDays.toInt())
}

View file

@ -40,6 +40,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM
import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoClickIntents
import com.tangem.utils.StringsSigns
@Composable
internal fun YieldSupplyPromoContent(
@ -257,11 +258,11 @@ private fun PromoBoostCard(baseApy: String, boostedApy: String, onLearnMoreClick
append(boostedApy)
}
}
val learnMoreLabel = stringResourceSafe(R.string.common_learn_more).lowercase()
val learnMoreLabel = stringResourceSafe(R.string.yield_apy_boost_promo_terms_and_conditions)
val eligibilityText = stringResourceSafe(R.string.yield_apy_boost_promo_eligibility_text)
val subtitleAnnotated = buildAnnotatedString {
append(eligibilityText)
append(" ")
append("${StringsSigns.COMA_SIGN} ")
withLink(
link = LinkAnnotation.Clickable(
tag = "YIELD_BOOST_LEARN_MORE",

View file

@ -43,12 +43,33 @@ internal class BoostBlockStateTest {
}
@Test
fun `GIVEN past qualificationEndDate WHEN resolve THEN AwaitingPayout`() {
fun `GIVEN qualificationEndDate passed within 14 days WHEN resolve THEN AwaitingPayout`() {
val result = resolveBoostBlockState(
qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"),
// 13d 23h 59m 59s ago — just inside the 14-day window
qualificationEndDate = Instant.parse("2026-05-14T00:00:01Z"),
now = now,
)
assertThat(result).isEqualTo(BoostBlockState.AwaitingPayout)
}
@Test
fun `GIVEN qualificationEndDate passed exactly 14 days ago WHEN resolve THEN Hidden`() {
val result = resolveBoostBlockState(
qualificationEndDate = Instant.parse("2026-05-14T00:00:00Z"),
now = now,
)
assertThat(result).isEqualTo(BoostBlockState.Hidden)
}
@Test
fun `GIVEN qualificationEndDate passed more than 14 days ago WHEN resolve THEN Hidden`() {
val result = resolveBoostBlockState(
qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"),
now = now,
)
assertThat(result).isEqualTo(BoostBlockState.Hidden)
}
}