Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-24 00:44:59 +03:00
parent 361bd8168b
commit 3da4771d66
19 changed files with 605 additions and 14 deletions

View file

@ -4,7 +4,9 @@ import com.tangem.blockchainsdk.utils.isNeedToCreateAccountWithoutReserve
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -34,6 +36,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
private val currencyChecksRepository: CurrencyChecksRepository,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(
@ -52,7 +55,12 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
flow2 = flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)),
flow3 = flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)),
flow4 = flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)),
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource ->
flow5 = if (currency is CryptoCurrency.Coin) {
dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, currency.network)
} else {
flowOf(false)
},
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource, hasExtraFunds ->
setOfNotNull(
maybeRentWarning,
maybeEdWarning?.let { getExistentialDepositWarning(currency, it) },
@ -64,6 +72,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency),
getMigrationFromMaticToPolWarning(currency),
getCloreMigrationWarning(currency),
DynamicAddressesWarnings.FundsFound.takeIf { hasExtraFunds },
)
}.flowOn(dispatchers.io)
}

View file

@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.MigrationClore,
is TokenDetailsNotification.UsedOutdatedData,
-> null
is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event
}
}
}

View file

@ -50,6 +50,8 @@ interface TokenDetailsClickIntents {
fun onDynamicAddressesClick()
fun onDynamicAddressesFundsFoundLearnMoreClick()
fun onCopyAddress(): TextReference?
fun onAssociateClick()
@ -129,6 +131,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onDynamicAddressesClick() { /* no op */ }
override fun onDynamicAddressesFundsFoundLearnMoreClick() { /* no op */ }
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }

View file

@ -707,6 +707,10 @@ internal class TokenDetailsModel @Inject constructor(
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
override fun onDynamicAddressesFundsFoundLearnMoreClick() {
// TODO: open "Learn more" URL once the destination is decided
}
private fun onDynamicAddressesStateChanged() {
updateTopBarMenu()
modelScope.launch(dispatchers.main) {

View file

@ -276,6 +276,17 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
data class DynamicAddressesFundsFound(
private val onLearnMoreClick: () -> Unit,
) : Warning(
title = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_title),
subtitle = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_description),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = R.string.common_learn_more),
onClick = onLearnMoreClick,
),
)
data class YieldSupplyNotTransferedToAave(val tokenName: String, val amount: String) : Warning(
title = resourceReference(
id = R.string.yield_module_amount_not_transfered_to_aave_title,

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.shorted
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -159,6 +160,9 @@ internal class TokenDetailsNotificationConverter(
onMigrationClick = clickIntents::onCloreMigrationClick,
)
is CryptoCurrencyWarning.UsedOutdatedDataWarning -> UsedOutdatedData
is DynamicAddressesWarnings.FundsFound -> DynamicAddressesFundsFound(
onLearnMoreClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick,
)
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
@ -164,6 +165,20 @@ internal class UpdateNotificationsTransformer(
),
),
)
is DynamicAddressesWarnings.FundsFound -> TangemMessageUM(
id = "dynamic_addresses_funds_found",
title = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_title),
subtitle = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_description),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.common_learn_more),
type = TangemButtonType.Primary,
onClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick,
),
),
)
// Non-warning types — skip for redesign
is CryptoCurrencyWarning.ExistentialDeposit,
is CryptoCurrencyWarning.Rent,

View file

@ -0,0 +1,146 @@
package com.tangem.feature.tokendetails.domain
import arrow.core.none
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
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.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class GetCurrencyWarningsUseCaseTest {
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true)
private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined)
private val userWalletId: UserWalletId = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true)
private val derivationPath: Network.DerivationPath = mockk(relaxed = true)
private val accountStatusList: AccountStatusList = mockk(relaxed = true)
private val useCase = GetCurrencyWarningsUseCase(
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
dispatchers = dispatchers,
currencyChecksRepository = currencyChecksRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
dynamicAddressesRepository = dynamicAddressesRepository,
)
@BeforeEach
fun setUp() {
// Bypass coin-related warnings flow: force both coin and token lookups to None so
// the use case falls through to `SomeNetworksUnreachable` without needing real data.
mockkObject(CryptoCurrencyStatusOperations)
with(CryptoCurrencyStatusOperations) {
every { accountStatusList.getCoinStatus(any<CryptoCurrency>()) } returns none()
every { accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>()) } returns none()
}
every { singleAccountStatusListSupplier(any<UserWalletId>()) } returns flowOf(accountStatusList)
coEvery { currencyChecksRepository.getRentInfoWarning(any(), any()) } returns null
coEvery { currencyChecksRepository.getExistentialDeposit(any(), any()) } returns null
coEvery { currencyChecksRepository.getFeeResourceAmount(any(), any()) } returns null
coEvery { walletManagersFacade.getAssetRequirements(any(), any()) } returns null
}
@AfterEach
fun tearDown() {
unmockkObject(CryptoCurrencyStatusOperations)
}
@Test
fun `GIVEN token currency WHEN invoke THEN FundsFound is absent and probe flow is not queried`() = runTest {
// GIVEN
val token: CryptoCurrency.Token = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
val currencyStatus = statusFor(token)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound)
verify(exactly = 0) {
dynamicAddressesRepository.hasFundsOnAdditionalAddresses(any(), any())
}
}
@Test
fun `GIVEN coin currency AND probe emits true WHEN invoke THEN FundsFound is present`() = runTest {
// GIVEN
val coin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(true)
val currencyStatus = statusFor(coin)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).contains(DynamicAddressesWarnings.FundsFound)
}
@Test
fun `GIVEN coin currency AND probe emits false WHEN invoke THEN FundsFound is absent`() = runTest {
// GIVEN
val coin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(false)
val currencyStatus = statusFor(coin)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound)
}
private fun statusFor(currency: CryptoCurrency): CryptoCurrencyStatus {
return mockk(relaxed = true) {
every { this@mockk.currency } returns currency
}
}
private class TestDispatchers(dispatcher: CoroutineDispatcher) : 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
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
class TokenDetailsNotificationConverterTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val userWalletId: UserWalletId = mockk(relaxed = true)
private val converter = TokenDetailsNotificationConverter(
userWalletId = userWalletId,
getUserWalletUseCase = getUserWalletUseCase,
clickIntents = clickIntents,
)
@Test
fun `GIVEN FundsFound warning WHEN convert THEN DynamicAddressesFundsFound notification is produced`() {
// WHEN
val result = converter.convert(setOf(DynamicAddressesWarnings.FundsFound))
// THEN
assertThat(result).hasSize(1)
assertThat(result.first()).isInstanceOf(TokenDetailsNotification.DynamicAddressesFundsFound::class.java)
}
@Test
fun `GIVEN FundsFound warning WHEN learn more button clicked THEN click intent is invoked`() {
// GIVEN
val notification = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)).first()
val button = notification.config.buttonsState as NotificationConfig.ButtonsState.SecondaryButtonConfig
// WHEN
button.onClick()
// THEN
verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() }
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
@ -449,6 +450,37 @@ class UpdateNotificationsTransformerTest {
verify(exactly = 1) { clickIntents.onDismissIncompleteTransactionClick() }
}
@Test
fun `GIVEN DynamicAddressesFundsFound WHEN transform THEN notification with learn more button is created`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(DynamicAddressesWarnings.FundsFound),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("dynamic_addresses_funds_found")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN DynamicAddressesFundsFound WHEN button clicked THEN onDynamicAddressesFundsFoundLearnMoreClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(DynamicAddressesWarnings.FundsFound),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() }
}
@Test
fun `GIVEN MigrationClore WHEN button clicked THEN onCloreMigrationClick is called`() {
// GIVEN