Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-06 16:35:06 +03:00
parent 4cd0ac3674
commit fcb3722ca0
7 changed files with 706 additions and 22 deletions

View file

@ -22,14 +22,18 @@ sealed class TokenScreenAnalyticsEvent(
blockchain: String,
token: String,
tokenBalance: TokenBalance,
isDynamicAddress: Boolean? = null,
) : AnalyticsEvent(
category = "Details Screen",
event = "Details Screen Opened",
params = mapOf(
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
BALANCE to tokenBalance.name,
),
params = buildMap {
put(BLOCKCHAIN, blockchain)
put(TOKEN_PARAM, token)
put(BALANCE, tokenBalance.name)
isDynamicAddress?.let {
put("Dynamic Address", if (it) "True" else "False")
}
},
) {
sealed class TokenBalance(val name: String) {
data object Full : TokenBalance("Full")

View file

@ -9,6 +9,31 @@ internal open class TokenDetailsAnalyticsEvent(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Token", event, params) {
class DynamicAddressesScreenOpened(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Screen Opened",
params = currency.toAnalyticsParams(),
)
class ButtonEnableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Button - Enable Dynamic Addresses",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesEnabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Enabled",
params = currency.toAnalyticsParams(),
)
class ButtonDisableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Button - Disable Dynamic Addresses",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesDisabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Disabled",
params = currency.toAnalyticsParams(),
)
open class Notice(
event: String,
params: Map<String, String> = mapOf(),
@ -19,14 +44,40 @@ internal open class TokenDetailsAnalyticsEvent(
params = currency.toAnalyticsParams(),
)
class NotEnoughFee(currency: CryptoCurrency) : Notice(
class NotEnoughFee(currency: CryptoCurrency, source: Source) : Notice(
event = "Not Enough Fee",
params = currency.toAnalyticsParams(),
)
params = currency.toAnalyticsParams() + ("Source" to source.value),
) {
enum class Source(val value: String) {
DetailedScreen("Detailed Screen"),
DynamicAddresses("Dynamic Addresses"),
}
}
class Reveal(currency: CryptoCurrency) : Notice(
event = "Reveal Transaction",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesUnavailable(currency: CryptoCurrency) : Notice(
event = "Dynamic Addresses Unavailable",
params = currency.toAnalyticsParams(),
)
class AdditionalAddressesFound(currency: CryptoCurrency) : Notice(
event = "Additional Addresses Found",
params = currency.toAnalyticsParams(),
)
}
open class Error(
event: String,
params: Map<String, String> = emptyMap(),
) : TokenDetailsAnalyticsEvent(event = "Error - $event", params) {
class DynamicAddressesUnavailable(currency: CryptoCurrency) : Error(
event = "Dynamic Addresses Unavailable",
params = currency.toAnalyticsParams(),
)
}
}

View file

@ -37,6 +37,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.NetworkFeeWithBuyButton,
-> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = cryptoCurrency,
source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DetailedScreen,
)
is TokenDetailsNotification.SwapPromo -> PromoAnalyticsEvent.NoticePromotionBanner(
program = PromoAnalyticsEvent.Program.Empty, // Use it on new promo action
@ -49,6 +50,10 @@ internal class TokenDetailsNotificationsAnalyticsSender(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
)
is TokenDetailsNotification.DynamicAddressesFundsFound ->
TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound(
currency = cryptoCurrency,
)
is TokenDetailsNotification.NetworksUnreachable,
is TokenDetailsNotification.ExistentialDeposit,
is TokenDetailsNotification.NetworksNoAccount,

View file

@ -1,6 +1,8 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
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.res.R
@ -23,6 +25,7 @@ import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
@ -37,7 +40,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DynamicAddressesDelegate @AssistedInject constructor(
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase,
@ -47,6 +50,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWallet: UserWallet,
@ -68,17 +72,20 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
)
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _bottomSheetConfig.asStateFlow()
private val resettableOneTimeEventSender = ResettableOneTimeEventSender(analyticsEventHandler)
// region Entry point
fun onDynamicAddressesClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency))
coroutineScope.launch(dispatchers.main) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).first()
val status = dynamicAddressesRepository.getStatus(userWalletId, currency.network).first()
when (status) {
DynamicAddressesStatus.ENABLED,
DynamicAddressesStatus.ENABLED_REQUIRES_SETUP,
-> onDisableFlow(network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(network)
-> onDisableFlow(currency.network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(currency.network)
}
}
}
@ -90,6 +97,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private suspend fun onEnableFlow(network: Network) {
val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
if (hasConflicts) {
cryptoCurrencyStatusProvider()?.currency?.let {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(it))
}
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
@ -106,7 +116,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onEnableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
@ -120,6 +132,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
dismissBottomSheet()
} else {
TangemLogger.e("Failed to get XPUB: ${error.message}")
analyticsEventHandler.send(
TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency),
)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
@ -131,21 +146,24 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
ifLeft = { error ->
when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens -> {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
analyticsEventHandler.send(
TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency),
)
_bottomSheetConfig.value = when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens ->
DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
}
is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
},
ifRight = {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesEnabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -190,10 +208,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onSimpleDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) }
.onSuccess {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -210,6 +231,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun showDisableSheetAndLoadFee() {
resettableOneTimeEventSender.reset(NOT_ENOUGH_FEE_EVENT_KEY)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
onDisableClick = ::onDisableClick,
@ -245,6 +267,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
cryptoCurrency = currency,
).fold(
ifLeft = {
resettableOneTimeEventSender.sendEventOnce(
key = NOT_ENOUGH_FEE_EVENT_KEY,
event = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = currency,
source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DynamicAddresses,
),
)
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
@ -282,7 +311,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
isSending = true,
@ -320,6 +351,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
} catch (e: Exception) {
TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}")
}
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -359,4 +391,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
@Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit,
): DynamicAddressesDelegate
}
private companion object {
const val NOT_ENOUGH_FEE_EVENT_KEY = "DynamicAddressesNotEnoughFee"
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
@ -176,6 +177,7 @@ internal class TokenDetailsModel @Inject constructor(
private val signCloreMessageUseCase: SignCloreMessageUseCase,
private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory,
private val dialogFactory: TokenDetailsDialogFactory,
private val userWalletsListRepository: UserWalletsListRepository,
@ -1258,7 +1260,7 @@ internal class TokenDetailsModel @Inject constructor(
return TokenDetailsBottomSheetConfig.Receive(receiveConfig)
}
private fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
private suspend fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
if (isBalanceLoadedEventSent || cryptoCurrencyStatus == null) return
val tokenBalance = when (val value = cryptoCurrencyStatus.value) {
@ -1290,11 +1292,19 @@ internal class TokenDetailsModel @Inject constructor(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
tokenBalance = tokenBalance,
isDynamicAddress = getIsDynamicAddressParam(),
),
)
isBalanceLoadedEventSent = true
}
private suspend fun getIsDynamicAddressParam(): Boolean? {
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(cryptoCurrency.network.rawId)) return null
return dynamicAddressesRepository
.isDynamicAddressesEnabledForNetwork(userWalletId, cryptoCurrency.network.id)
.first()
}
private suspend fun needShowYieldSupplyWarning(): Boolean {
return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.analytics
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.collections.immutable.toPersistentList
import org.junit.jupiter.api.Test
internal class TokenDetailsNotificationsAnalyticsSenderTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { name } returns "Ethereum"
}
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { symbol } returns "ETH"
every { this@mockk.network } returns this@TokenDetailsNotificationsAnalyticsSenderTest.network
}
private val sender = TokenDetailsNotificationsAnalyticsSender(
cryptoCurrency = cryptoCurrency,
analyticsEventHandler = analyticsEventHandler,
)
@Test
fun `GIVEN NetworkFee notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() {
// GIVEN
val notification = mockk<TokenDetailsNotification.NetworkFee>()
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Notice - Not Enough Fee")
assertThat(event.params).containsEntry("Token", "ETH")
assertThat(event.params).containsEntry("Blockchain", "Ethereum")
assertThat(event.params).containsEntry("Source", "Detailed Screen")
}
@Test
fun `GIVEN NetworkFeeWithBuyButton notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() {
// GIVEN
val notification = mockk<TokenDetailsNotification.NetworkFeeWithBuyButton>()
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee
assertThat(event.params).containsEntry("Source", "Detailed Screen")
}
@Test
fun `GIVEN DynamicAddressesFundsFound notification WHEN send THEN AdditionalAddressesFound event is sent`() {
// GIVEN
val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {})
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 1) { analyticsEventHandler.send(any()) }
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Notice - Additional Addresses Found")
assertThat(event.params).containsEntry("Token", "ETH")
assertThat(event.params).containsEntry("Blockchain", "Ethereum")
}
@Test
fun `GIVEN empty new notifications WHEN send THEN no event is sent`() {
// GIVEN
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = emptyList())
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN pullToRefresh is refreshing WHEN send THEN no event is sent`() {
// GIVEN
val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {})
val displayedState = createState(notifications = emptyList(), isRefreshing = true)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN notification without matching event WHEN send THEN no event is sent`() {
// GIVEN: NetworksUnreachable is an unmapped notification (returns null in getEvent)
val notification = TokenDetailsNotification.NetworksUnreachable
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
private fun createState(
notifications: List<TokenDetailsNotification>,
isRefreshing: Boolean,
): TokenDetailsState {
return mockk(relaxed = true) {
every { this@mockk.notifications } returns notifications.toPersistentList()
every { pullToRefreshConfig.isRefreshing } returns isRefreshing
}
}
}

View file

@ -0,0 +1,441 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
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.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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
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.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.Provider
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.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.math.BigDecimal
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"
@OptIn(ExperimentalCoroutinesApi::class)
internal class DynamicAddressesDelegateTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase = mockk()
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase = mockk()
private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase = mockk()
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk()
private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true)
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { name } returns BLOCKCHAIN_NAME
}
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { symbol } returns TOKEN_SYMBOL
every { this@mockk.network } returns this@DynamicAddressesDelegateTest.network
}
private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) {
every { currency } returns cryptoCurrency
}
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val userWallet: UserWallet = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val showBottomSheet: () -> Unit = mockk(relaxed = true)
private val dismissBottomSheet: () -> Unit = mockk(relaxed = true)
private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
delegate.onDynamicAddressesClick()
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Dynamic Addresses Screen Opened")
assertThat(event.params).containsEntry("Token", TOKEN_SYMBOL)
assertThat(event.params).containsEntry("Blockchain", BLOCKCHAIN_NAME)
}
@Test
fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest {
// GIVEN
val delegate = createDelegate(cryptoCurrencyStatus = null)
// WHEN
delegate.onDynamicAddressesClick()
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns true
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN
delegate.onDynamicAddressesClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable> {
it.event == "Notice - Dynamic Addresses Unavailable" &&
it.params["Token"] == TOKEN_SYMBOL &&
it.params["Blockchain"] == BLOCKCHAIN_NAME
},
)
}
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND enable succeeds THEN ButtonEnable and DynamicAddressesEnabled are sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right()
coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses> {
it.event == "Button - Enable Dynamic Addresses" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled> {
it.event == "Dynamic Addresses Enabled" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
}
coVerify { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) }
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND xpub retrieval fails THEN Error DynamicAddressesUnavailable is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns
IllegalStateException("xpub fail").left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable> {
it.event == "Error - Dynamic Addresses Unavailable" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
}
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND user cancels xpub derivation THEN Error event is NOT sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns
TangemSdkError.UserCancelled().left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable>())
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled>())
}
}
@Test
fun `GIVEN DISABLED status WHEN enable useCase fails THEN Error DynamicAddressesUnavailable is sent`() = runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right()
coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns
EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable>())
}
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled>())
}
}
@Test
fun `GIVEN ENABLED status AND no consolidation WHEN simple disable clicked THEN ButtonDisable and DynamicAddressesDisabled are sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.ENABLED)
coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns false.right()
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation)
.onDisableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses> {
it.event == "Button - Disable Dynamic Addresses"
},
)
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled> {
it.event == "Dynamic Addresses Disabled"
},
)
}
}
@Test
fun `GIVEN consolidation required AND fee fails WHEN load fee THEN NotEnoughFee with DynamicAddresses source is sent`() =
runTest {
// GIVEN: consolidation required, status provides balance and address, fee load fails
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val events = mutableListOf<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(events)) } returns Unit
// WHEN
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// THEN
val notEnoughFee = events
.filterIsInstance<TokenDetailsAnalyticsEvent.Notice.NotEnoughFee>()
.single()
assertThat(notEnoughFee.event).isEqualTo("Notice - Not Enough Fee")
assertThat(notEnoughFee.params).containsEntry("Source", "Dynamic Addresses")
assertThat(notEnoughFee.params).containsEntry("Token", TOKEN_SYMBOL)
assertThat(notEnoughFee.params).containsEntry("Blockchain", BLOCKCHAIN_NAME)
}
@Test
fun `GIVEN consolidation required AND fee fails WHEN load fee retried THEN NotEnoughFee is sent only once`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN: initial load + refresh
delegate.onDynamicAddressesClick()
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onRefreshFee()
// THEN: one-time event sender collapses repeated errors
verify(exactly = 1) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Notice.NotEnoughFee>())
}
}
@Test
fun `GIVEN consolidation flow WHEN disable clicked AND tx succeeds THEN ButtonDisable and DynamicAddressesDisabled are sent`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val txData = mockk<TransactionData>(relaxed = true)
coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right()
coEvery { sendTransactionUseCase(txData, userWallet, network) } returns "tx-hash".right()
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onDisableClick()
// THEN
verify {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses>())
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled>())
}
coVerify { sendTransactionUseCase(txData, userWallet, network) }
}
@Test
fun `GIVEN consolidation flow WHEN disable clicked AND tx cancelled by user THEN DynamicAddressesDisabled is NOT sent`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val txData = mockk<TransactionData>(relaxed = true)
coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right()
coEvery { sendTransactionUseCase(txData, userWallet, network) } returns
SendTransactionError.UserCancelledError.left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onDisableClick()
// THEN: ButtonDisable is sent on click, but success event is not
verify { analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses>()) }
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled>())
}
}
@Test
fun `GIVEN Notice category event WHEN sent THEN id starts with Token category`() {
// GIVEN
val notice = TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(cryptoCurrency)
val error = TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(cryptoCurrency)
// THEN
assertThat(notice.category).isEqualTo("Token")
assertThat(notice.event).isEqualTo("Notice - Dynamic Addresses Unavailable")
assertThat(error.category).isEqualTo("Token")
assertThat(error.event).isEqualTo("Error - Dynamic Addresses Unavailable")
}
private fun setupConsolidationFlow() {
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.ENABLED)
coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns true.right()
val address = NetworkAddress.Address(value = TEST_ADDRESS, type = NetworkAddress.Address.Type.Primary)
every { cryptoCurrencyStatus.value } returns mockk(relaxed = true) {
every { amount } returns BigDecimal.ONE
every { fiatRate } returns BigDecimal.ONE
every { networkAddress } returns NetworkAddress.Single(defaultAddress = address)
}
}
private fun createDelegate(cryptoCurrencyStatus: CryptoCurrencyStatus?): DynamicAddressesDelegate {
val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
return DynamicAddressesDelegate(
enableDynamicAddressesUseCase = enableDynamicAddressesUseCase,
disableDynamicAddressesUseCase = disableDynamicAddressesUseCase,
createConsolidationTransactionUseCase = createConsolidationTransactionUseCase,
getFeeUseCase = getFeeUseCase,
sendTransactionUseCase = sendTransactionUseCase,
getDerivedXpubUseCase = getDerivedXpubUseCase,
dynamicAddressesRepository = dynamicAddressesRepository,
getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
dispatchers = TestingCoroutineDispatcherProvider(),
userWallet = userWallet,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider { mockk<AppCurrency>(relaxed = true) },
coroutineScope = scope,
showBottomSheet = showBottomSheet,
dismissBottomSheet = dismissBottomSheet,
onDynamicAddressesStateChanged = onDynamicAddressesStateChanged,
)
}
}