Updated on 2026-08-14
This commit is contained in:
commit
81bf4b5296
128 changed files with 4244 additions and 477 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -782,7 +782,7 @@ internal class SwapTransferStateBuilderTest {
|
|||
tokenSymbol = stringReference(""),
|
||||
amountEquivalent = TextReference.EMPTY,
|
||||
amountField = initialAmountField,
|
||||
balance = "",
|
||||
balance = TextReference.EMPTY,
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
receiveCardData = SwapCardState.Loading(
|
||||
|
|
|
|||
|
|
@ -482,7 +482,6 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
cryptoAmount = data.cryptoBalance,
|
||||
fiatAmount = data.fiatBalance,
|
||||
depositAddress = data.depositAddress,
|
||||
isWithdrawal = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -64,4 +64,5 @@ dependencies {
|
|||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.coroutine)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue