Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 16:53:28 +04:00
commit b654c6ea6e
35 changed files with 1279 additions and 165 deletions

View file

@ -1581,6 +1581,7 @@ internal class SwapInteractorImpl @Inject constructor(
feeValue = nativeFee,
selectedFeeToken = fee.selectedFeeToken,
provider = state.swapProvider,
txType = state.txType,
)
val currencyCheck = manageWarnings(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -1621,6 +1622,7 @@ internal class SwapInteractorImpl @Inject constructor(
* from-currencies but "fee > native balance" for Token from-currencies is resolved here
* by consulting `isBalanceEnough` (amount-alone check) directly.
*/
@Suppress("LongParameterList")
private suspend fun computeBalanceStatus(
fromSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
@ -1628,9 +1630,10 @@ internal class SwapInteractorImpl @Inject constructor(
feeValue: BigDecimal,
selectedFeeToken: CryptoCurrencyStatus?,
provider: SwapProvider,
txType: ExpressTxType?,
): SwapBalanceStatus {
when (provider.type) {
ExchangeProviderType.CEX -> {
when (resolveQuoteFlow(provider, txType)) {
ResolvedFlow.CexLike -> {
val includeStatus = getIncludeFeeInAmountInternal(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
amount = amount,
@ -1642,9 +1645,7 @@ internal class SwapInteractorImpl @Inject constructor(
return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee)
}
}
ExchangeProviderType.DEX,
ExchangeProviderType.DEX_BRIDGE,
-> Unit
ResolvedFlow.DexLike -> Unit
}
val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue)

View file

@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.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.ui.*
@ -214,6 +215,76 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
}
}
// =========================================================================
// Section A2: DEX provider re-routed to the CEX-like flow via txType=SEND ([REDACTED_TASK_KEY])
// =========================================================================
@Nested
inner class `DEX provider with SEND txType follows CEX semantics` {
/**
* [REDACTED_TASK_KEY]: a DEX-typed provider (e.g. Moonpay trade) whose quote returned txType=SEND
* executes as a plain transfer built by the app, so the fee must be folded into the amount
* exactly like for a CEX provider.
*
* GIVEN ExchangeProviderType.DEX, txType = SEND
* fromToken is Coin, amount = full native balance (max amount), fee = 0.01
* WHEN applySwapFee runs
* THEN balanceStatus == FeeAdjustedAmount with adjustedAmount = balance - fee
* (NOT InsufficientAmount the pre-fix behavior that showed "Insufficient funds")
*/
@Test
fun `applySwapFee DEX with SEND txType — max amount returns FeeAdjustedAmount like CEX`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("1.0"),
txType = ExpressTxType.SEND,
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
val balanceStatus = result.preparedSwapConfigState.balanceStatus
assertThat(balanceStatus).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
assertThat((balanceStatus as SwapBalanceStatus.FeeAdjustedAmount).adjustedAmount.value)
.isEqualTo(BigDecimal("0.99"))
}
/**
* Twin guard: the same max-amount scenario with txType = SWAP keeps the DEX invariant
* the fee is never deducted from the amount, and the amount alone exceeding
* balance-with-fee yields InsufficientAmount.
*/
@Test
fun `applySwapFee DEX with SWAP txType — max amount keeps DEX semantics without fee deduction`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("1.0"),
txType = ExpressTxType.SWAP,
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
}
}
// =========================================================================
// Section B: FeePaidCurrency.Token (gasless-token) paths
// =========================================================================
@ -749,6 +820,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
fromAmount: SwapAmount,
isCoin: Boolean,
fromBalance: BigDecimal,
txType: ExpressTxType? = null,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
@ -778,6 +850,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(providerType),
txType = txType,
)
}

View file

@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
@ -327,11 +328,16 @@ internal class SwapNotificationsFactory(
val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeCryptoCurrencyStatus.currency != fromCurrency
val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX
// A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer,
// so it must follow the same gasless suppression rule as a real CEX provider.
val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX ||
quoteModel.txType == ExpressTxType.SEND
val isNotEnoughFee = insufficientFee != null && !isCEXProvider
val isNotEnoughFee = insufficientFee != null
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
// Suppress only when the user can actually switch the fee to a token via the gasless
// selector; on networks without gasless support the warning must show for CEX too.
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow
if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) {
add(
if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) {

View file

@ -0,0 +1,276 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTxType
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.utils.Provider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the
* `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]):
*
* | flow | gasless network | expected for InsufficientFee |
* |-------------------------------|-----------------|--------------------------------|
* | CEX | no | warning shown (the bug fix) |
* | CEX | yes | suppressed (fee token) |
* | DEX (txType=null) | yes | warning shown (DEX unchanged) |
* | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX |
* | DEX + txType=SEND (CEX-like) | no | warning shown |
*/
internal class SwapNotificationsFactoryTest {
private val actions: UiActions = mockk(relaxed = true)
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = Provider { AppCurrency.Default }
private val appRouter: AppRouter = mockk()
private val factory = SwapNotificationsFactory(
actions = actions,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
appCurrencyProvider = appCurrencyProvider,
)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val userWallet: UserWallet = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
@BeforeEach
fun resetMocks() {
clearMocks(isGaslessFeeSupportedForNetwork, appRouter)
every { appRouter.stack } returns emptyList()
}
@Test
fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() {
// Arrange
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
// Act
val notifications = factory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
swapFee = null,
feeError = null,
appRouter = appRouter,
)
// Assert
val warning = notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>().single()
assertThat(warning.currencyName).isEqualTo("Ethereum")
assertThat(warning.currencySymbol).isEqualTo("ETH")
}
@Test
fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() {
// Arrange
every { isGaslessFeeSupportedForNetwork(any()) } returns true
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX)
// Act
val notifications = factory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
swapFee = null,
feeError = null,
appRouter = appRouter,
)
// Assert
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
}
@Test
fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() {
// Arrange
every { isGaslessFeeSupportedForNetwork(any()) } returns true
val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX)
// Act
val notifications = factory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
swapFee = null,
feeError = null,
appRouter = appRouter,
)
// Assert
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
}
@Test
fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() {
// Arrange
every { isGaslessFeeSupportedForNetwork(any()) } returns true
val quoteModel = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
txType = ExpressTxType.SEND,
)
// Act
val notifications = factory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
swapFee = null,
feeError = null,
appRouter = appRouter,
)
// Assert
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).isEmpty()
}
@Test
fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() {
// Arrange
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
txType = ExpressTxType.SEND,
)
// Act
val notifications = factory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = buildCoinFeeStatus(),
swapFee = null,
feeError = null,
appRouter = appRouter,
)
// Assert
assertThat(notifications.filterIsInstance<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).hasSize(1)
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun buildEthNetwork(): Network = mockk(relaxed = true) {
every { rawId } returns "ethereum"
every { name } returns "Ethereum"
every { currencySymbol } returns "ETH"
}
/** Token from-currency, so the fee is paid in a different (native coin) currency. */
private fun buildTokenFromStatus(): SwapCurrencyStatus {
val network = buildEthNetwork()
val currency = mockk<CryptoCurrency.Token>(relaxed = true) {
every { this@mockk.network } returns network
every { symbol } returns "USDT"
every { name } returns "Tether"
every { decimals } returns 6
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("100")
every { pendingTransactions } returns emptySet()
}
return SwapCurrencyStatus(
userWallet = userWallet,
status = CryptoCurrencyStatus(currency = currency, value = statusValue),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
}
private fun buildCoinFeeStatus(): CryptoCurrencyStatus {
val network = buildEthNetwork()
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { symbol } returns "ETH"
every { name } returns "Ethereum"
every { decimals } returns 18
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal.ZERO
}
return CryptoCurrencyStatus(currency = currency, value = statusValue)
}
private fun buildQuotesLoadedState(
providerType: ExchangeProviderType,
txType: ExpressTxType? = null,
): SwapState.QuotesLoadedState {
val toStatusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("1")
}
val toCurrency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns buildEthNetwork()
every { symbol } returns "BTC"
every { decimals } returns 8
}
val toSwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("50"), 6),
swapCurrencyStatus = buildTokenFromStatus(),
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 8),
swapCurrencyStatus = toSwapCurrencyStatus,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.InsufficientFee(
feeCurrencyName = "Ethereum",
feeCurrencySymbol = "ETH",
),
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.Empty,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildProvider(providerType),
txType = txType,
)
}
private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider(
providerId = "p",
rateTypes = listOf(RateType.FLOAT),
name = "Provider",
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
)
}

View file

@ -29,7 +29,6 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.model.TangemPayTopUpData
@ -129,7 +128,6 @@ internal class TangemPayDetailsModel @Inject constructor(
private val refreshStateJobHolder = JobHolder()
private val addToWalletBannerJobHolder = JobHolder()
private val frozenStateJobHolder = JobHolder()
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
@ -162,9 +160,6 @@ internal class TangemPayDetailsModel @Inject constructor(
isMuted = !state.isFresh,
)
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
state.cards.firstOrNull()?.let { card ->
subscribeToCardFrozenState(card.id)
}
}
else -> uiState.update { stateFactory.getLoadingState() }
}
@ -195,33 +190,11 @@ internal class TangemPayDetailsModel @Inject constructor(
fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled
private fun subscribeToCardFrozenState(cardId: String) {
frozenStateJobHolder.cancel()
cardDetailsRepository
.cardFrozenState(cardId)
.onEach { frozenState ->
// Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data.
val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true
val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen
val areActionButtonsEnabled = isFresh && isUnfrozen
val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true
uiState.update(
TangemPayActionButtonsTransformer(
stateFactory.getActionButtonsConfig(
isAddFundsEnabled = areActionButtonsEnabled,
isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance,
),
),
)
}
.launchIn(modelScope)
.saveIn(frozenStateJobHolder)
}
override fun onClickAddFunds() {
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
val balance = currentStatus.value.balanceOrNull()
if (balance == null) {
val address = currentStatus.value.ifLoadedOrNull { it.depositAddress }
if (balance == null || address.isNullOrEmpty()) {
showBottomSheetError(TangemPayDetailsErrorType.Receive)
} else {
bottomSheetNavigation.activate(

View file

@ -178,7 +178,11 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals
},
valueSlot = {
if (value.isEmpty()) {
TangemShimmer(style = TangemTheme.typography3.body.medium)
TangemShimmer(
modifier = Modifier.width(80.dp),
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.End,
)
} else {
TangemRowText(
text = value,
@ -299,7 +303,7 @@ private fun ReissueCardSheetPreview(state: TangemPayReissueCardUM) {
private class TangemPayReissueCardUMPreviewProvider : CollectionPreviewParameterProvider<TangemPayReissueCardUM>(
collection = listOf(
TangemPayReissueCardUM.stub(error = null),
TangemPayReissueCardUM.stub(error = null, feeAmount = ""),
TangemPayReissueCardUM.stub(
error = TangemPayReissueCardError.InsufficientFunds,
cardBalance = "$0.05",

View file

@ -1,11 +1,8 @@
package com.tangem.features.tangempay.utils
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.findCardWithId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.wallet.UserWalletId
internal val AccountStatus.Payment.userWalletId: UserWalletId
@ -22,14 +19,12 @@ internal val AccountStatus.Payment.isDeactivated: Boolean
get() = value is PaymentAccountStatusValue.Deactivated
internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
get() = source == StatusSource.ACTUAL && error == null
get() = source.isActual() && error == null
internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded =
value as? PaymentAccountStatusValue.Loaded
?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}")
internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first()
internal inline fun <T> AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccountStatusValue.Loaded) -> T): T? {
val value = value
return if (value is PaymentAccountStatusValue.Loaded) {
@ -46,20 +41,4 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba
}
internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean
get() = availableForWithdrawal.signum() > 0
internal fun AccountStatus.Payment.findCard(
initialCardId: String,
initialStatus: AccountStatus.Payment,
): TangemPayCard? {
val value = value
if (value !is PaymentAccountStatusValue.Loaded || value.source != StatusSource.ACTUAL) return null
val initialCard = value.findCardWithId(initialCardId)
val newCards = initialStatus.ifLoadedOrNull { status ->
val initialCardIds = status.cards.mapTo(mutableSetOf()) { it.id }
value.cards.filterNot { it.id in initialCardIds }
}
return initialCard ?: newCards?.firstOrNull()
}
get() = availableForWithdrawal.signum() > 0

View file

@ -37,27 +37,6 @@ internal class TangemPayDetailsModelTest {
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true)
@ParameterizedTest
@MethodSource("provideFreezeCases")
fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`(
case: FreezeCase,
) = runTest {
// Arrange + Act
val model = createModel(
testScope = this,
statusSource = case.statusSource,
frozenState = case.frozenState,
availableForWithdrawal = case.availableForWithdrawal,
)
advanceUntilIdle()
// Assert
val state = model.uiState.value
assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled)
assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled)
model.onDestroy()
}
@ParameterizedTest
@MethodSource("provideMutedCases")
fun `GIVEN status source WHEN status loaded THEN balance is muted only when cached`(case: MutedCase) = runTest {

View file

@ -31,7 +31,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@Suppress("LongParameterList")
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
}
}
// Refresh the portfolio before searching so a token just added on the backend is present locally.
refreshAccountsIfNeeded(userWallet)
val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId)
if (cryptoCurrency == null) {
TangemLogger.e(
@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
}
}
/**
* Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token.
*
* A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match,
* so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect.
*/
private suspend fun resolveCryptoCurrency(
userWallet: UserWallet,
networkId: String?,
tokenId: String?,
): CryptoCurrency? {
if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null
val wasRefreshed = refreshAccountsIfNeeded(userWallet)
return findCryptoCurrency(
userWallet = userWallet,
networkId = networkId,
tokenId = tokenId,
awaitOnMiss = wasRefreshed,
)
}
/**
* Refreshes wallet accounts so a token just added on the backend appears in the local portfolio.
*
@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
* on cold start the fresh list is already loaded by the regular auth flow, and single-currency
* wallets have a fixed token. The fetch is best-effort on failure we fall through and try the
* current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression.
*
* @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed
* list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for.
*/
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) {
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean {
if (isFromOnNewIntent && userWallet.isMultiCurrency) {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
.onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) }
.isRight()
}
return false
}
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
}
}
private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) =
if (userWallet.isMultiCurrency) {
val derivationPath = queryParams[DERIVATION_PATH_KEY]
getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency ->
val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true)
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card
val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true
val isCorrectDerivation = isDefaultDerivation || isCustomDerivation
isNetwork && isCurrency && isCorrectDerivation
}
} else {
singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
private suspend fun findCryptoCurrency(
userWallet: UserWallet,
networkId: String?,
tokenId: String?,
awaitOnMiss: Boolean,
): CryptoCurrency? {
if (!userWallet.isMultiCurrency) {
return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
?.mainAccount?.cryptoCurrencies?.first()
}
private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>? {
return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies()
val derivationPath = queryParams[DERIVATION_PATH_KEY]
val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) }
return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches)
// getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list.
// Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full
// timeout before the fall-through redirect.
?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null
}
private suspend fun awaitCryptoCurrency(
userWalletId: UserWalletId,
matches: (CryptoCurrency) -> Boolean,
): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) {
singleAccountListSupplier(userWalletId)
.mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) }
.firstOrNull()
}
private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean {
val isNetwork = network.rawId.equals(networkId, ignoreCase = true)
val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card
val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true
return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation)
}
@AssistedFactory
@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
isFromOnNewIntent: Boolean,
): DefaultTokenDetailsDeepLinkHandler
}
private companion object {
const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L
}
}

View file

@ -1,12 +1,7 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -103,7 +98,6 @@ private fun QuickTopUpBlock_Preview() {
),
),
),
modifier = Modifier.padding(TangemTheme.dimens2.x3),
)
}
}

View file

@ -226,7 +226,7 @@ private fun TokenDetailsBody(
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
modifier = itemModifier.padding(top = 8.dp),
)
}
}

View file

@ -1,10 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -87,7 +83,7 @@ private fun SwapAndSendActionRow(state: TransferUM) {
if (state is TransferUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_send_with_swap),
title = resourceReference(CoreR.string.send_with_swap_confirm_title),
description = resourceReference(CoreR.string.quick_action_send_and_swap_description),
row = row,
isLoading = state is TransferUM.Loading,

View file

@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger
import io.mockk.*
import kotlinx.coroutines.CoroutineScope
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
@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow(
AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()),
)
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
}
}
@Test
fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest {
val userWalletId = UserWalletId("011")
val cryptoCurrency = mockCryptoCurrency()
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList())
val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency))
val accountListFlow = MutableStateFlow(staleList)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList
every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow
coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers {
accountListFlow.value = freshList
Either.Right(Unit)
}
every {
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency)
} just Runs
val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency)
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
verify { appRouter.push(route = expectedRoute, onComplete = any()) }
verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
}
@Test
fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest {
// Arrange
val userWalletId = UserWalletId("011")
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = emptyList(),
)
// Act
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false)
advanceUntilIdle()
// Assert
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
}
@Test
fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() =
runTest {
// Arrange
val userWalletId = UserWalletId("011")
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery {
singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId))
} returns Either.Left(IllegalStateException("service unavailable"))
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = emptyList(),
)
// Act
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
// Assert
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
}
@Test
fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() =
runTest {
// Arrange
val userWalletId = UserWalletId("011")
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = emptyList(),
)
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
DERIVATION_PATH_KEY to "777",
// TOKEN_ID_KEY is missing
)
// Act
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
advanceUntilIdle()
// Assert
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
verify(exactly = 0) { singleAccountListSupplier.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
private fun defaultQueryParams() = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",

View file

@ -129,8 +129,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
data class BackupError(val onClick: () -> Unit) : WalletNotificationUM(
messageUM = TangemMessageUM(
id = "BackupErrorNotification",
title = resourceReference(id = R.string.warning_backup_errors_title),
subtitle = resourceReference(id = R.string.warning_backup_errors_message),
title = resourceReference(id = R.string.warning_incomplete_backup_notification_title),
subtitle = resourceReference(id = R.string.warning_incomplete_backup_notification_message),
messageEffect = TangemMessageEffect.Warning,
iconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,

View file

@ -12,9 +12,9 @@ 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.domain.staking.model.optionOrNull
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
@ -34,7 +34,7 @@ internal class EarnApyConverter(
)
}?.value
if (yieldSupplyApy != null) {
val isActive = value.value.yieldSupplyStatus?.isActive == false
val isActive = value.value.yieldSupplyStatus?.isActive == true
return EarnApyInfo(
text = resourceReference(
R.string.yield_module_earn_badge,