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

@ -9,6 +9,10 @@ android {
namespace = "com.tangem.data.dynamicaddresses"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.configToggles)
@ -37,4 +41,9 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Testing
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.test.core)
// endregion
}

View file

@ -6,6 +6,9 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
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.network.Network
@ -16,18 +19,30 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
@Suppress("LongParameterList")
internal class DefaultDynamicAddressesRepository(
private val walletAccountsFetcher: WalletAccountsFetcher,
private val walletAccountsSaver: WalletAccountsSaver,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val walletManagersFacade: WalletManagersFacade,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dispatchers: CoroutineDispatcherProvider,
) : DynamicAddressesRepository {
private val extraFundsProbeCache = ConcurrentHashMap<Pair<UserWalletId, Network>, Boolean>()
private val extraFundsProbeMutex = Mutex()
override fun getStatus(userWalletId: UserWalletId, network: Network): Flow<DynamicAddressesStatus> {
return walletAccountsFetcher.get(userWalletId)
.map { response ->
@ -49,6 +64,7 @@ internal class DefaultDynamicAddressesRepository(
error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
invalidateExtraFundsProbe(userWalletId, network)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { throwable ->
TangemLogger.e(
@ -66,6 +82,7 @@ internal class DefaultDynamicAddressesRepository(
error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
invalidateExtraFundsProbe(userWalletId, network)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { throwable ->
TangemLogger.e(
@ -89,6 +106,40 @@ internal class DefaultDynamicAddressesRepository(
return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network)
}
override fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow<Boolean> {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return flowOf(false)
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.id.rawId.value)) return flowOf(false)
return getStatus(userWalletId, network)
.distinctUntilChanged()
.map { status ->
if (status != DynamicAddressesStatus.DISABLED) return@map false
probeExtraFundsCached(userWalletId, network)
}
.onStart { emit(false) }
.flowOn(dispatchers.io)
}
private suspend fun probeExtraFundsCached(userWalletId: UserWalletId, network: Network): Boolean {
val key = userWalletId to network
extraFundsProbeCache[key]?.let { return it }
return extraFundsProbeMutex.withLock {
extraFundsProbeCache[key]?.let { return@withLock it }
val xpub = getDerivedXpubUseCase(userWalletId, network) ?: return@withLock false
val hasFunds = walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, xpub)
if (hasFunds) extraFundsProbeCache[key] = true
hasFunds
}
}
private suspend fun invalidateExtraFundsProbe(userWalletId: UserWalletId, network: Network) {
extraFundsProbeMutex.withLock {
extraFundsProbeCache.remove(userWalletId to network)
}
}
override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean {
return withContext(dispatchers.io) {
val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false

View file

@ -8,6 +8,7 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesFeatureToggles
import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -29,6 +30,8 @@ internal object DynamicAddressesDataModule {
walletAccountsSaver: WalletAccountsSaver,
accountsCRUDRepository: AccountsCRUDRepository,
walletManagersFacade: WalletManagersFacade,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
getDerivedXpubUseCase: GetDerivedXpubUseCase,
dispatchers: CoroutineDispatcherProvider,
): DynamicAddressesRepository {
return DefaultDynamicAddressesRepository(
@ -36,6 +39,8 @@ internal object DynamicAddressesDataModule {
walletAccountsSaver = walletAccountsSaver,
accountsCRUDRepository = accountsCRUDRepository,
walletManagersFacade = walletManagersFacade,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
getDerivedXpubUseCase = getDerivedXpubUseCase,
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,218 @@
package com.tangem.data.dynamicaddresses
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class DefaultDynamicAddressesRepositoryTest {
private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxed = true)
private val walletAccountsSaver: WalletAccountsSaver = mockk(relaxed = true)
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val featureToggles: DynamicAddressesFeatureToggles = mockk(relaxed = true)
private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk(relaxed = true)
private val userWalletId: UserWalletId = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { id.rawId.value } returns SUPPORTED_NETWORK_ID
}
private val otherNetwork: Network = mockk(relaxed = true) {
every { id.rawId.value } returns OTHER_SUPPORTED_NETWORK_ID
}
private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined)
private lateinit var repository: DefaultDynamicAddressesRepository
@BeforeEach
fun setUp() {
clearMocks(walletManagersFacade, featureToggles, getDerivedXpubUseCase, answers = false)
// Empty response → findToken returns null → getStatus emits DISABLED.
val emptyResponse = mockk<GetWalletAccountsResponse>(relaxed = true) {
every { accounts } returns emptyList()
}
every { walletAccountsFetcher.get(userWalletId) } returns flowOf(emptyResponse)
coEvery { walletManagersFacade.enableXpubMode(any(), any(), any()) } returns SimpleResult.Success
coEvery { walletManagersFacade.disableXpubMode(any(), any()) } returns SimpleResult.Success
repository = DefaultDynamicAddressesRepository(
walletAccountsFetcher = walletAccountsFetcher,
walletAccountsSaver = walletAccountsSaver,
accountsCRUDRepository = accountsCRUDRepository,
walletManagersFacade = walletManagersFacade,
dynamicAddressesFeatureToggles = featureToggles,
getDerivedXpubUseCase = getDerivedXpubUseCase,
dispatchers = dispatchers,
)
}
@Test
fun `GIVEN feature toggle off WHEN collect THEN probe is never called and flow emits false`() = runTest {
// GIVEN
every { featureToggles.isDynamicAddressesEnabled } returns false
// WHEN
val values = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN
assertThat(values).doesNotContain(true)
coVerify(exactly = 0) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any())
}
}
@Test
fun `GIVEN toggle on AND xpub is null WHEN collect THEN probe is not called AND cache is empty`() = runTest {
// GIVEN
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns null
// WHEN
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// Second collect should invoke xpub derivation again (nothing is cached for null xpub).
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN
coVerify(exactly = 2) { getDerivedXpubUseCase(userWalletId, network) }
coVerify(exactly = 0) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any())
}
}
@Test
fun `GIVEN probe returns true WHEN collect THEN result is cached AND next collect skips probe`() = runTest {
// GIVEN
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB
coEvery {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
} returns true
// WHEN
val firstValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
val secondValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN
assertThat(firstValues).contains(true)
assertThat(secondValues).contains(true)
coVerify(exactly = 1) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
}
}
@Test
fun `GIVEN probe returns false WHEN collect twice THEN probe runs each time`() = runTest {
// GIVEN
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB
coEvery {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
} returns false
// WHEN
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN — negative results are not cached, so the probe must re-run.
coVerify(exactly = 2) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
}
}
@Test
fun `GIVEN cached true WHEN enable succeeds THEN cache is invalidated and next probe runs again`() = runTest {
// GIVEN — populate cache with a positive probe
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB
coEvery {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
} returns true
repository.hasFundsOnAdditionalAddresses(userWalletId, network).first()
// WHEN — enable() succeeds and invalidates the cache
repository.enable(userWalletId, network, XPUB)
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN — probe was called once before enable, again after invalidation
coVerify(exactly = 2) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
}
}
@Test
fun `GIVEN cached true WHEN disable succeeds THEN cache is invalidated and next probe runs again`() = runTest {
// GIVEN
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB
coEvery {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
} returns true
repository.hasFundsOnAdditionalAddresses(userWalletId, network).first()
// WHEN
repository.disable(userWalletId, network)
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN
coVerify(exactly = 2) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
}
}
@Test
fun `GIVEN cache entry for one network WHEN invalidate other network THEN first entry is preserved`() = runTest {
// GIVEN — cache populated for `network`
every { featureToggles.isDynamicAddressesEnabled } returns true
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB
coEvery {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
} returns true
repository.hasFundsOnAdditionalAddresses(userWalletId, network).first()
// WHEN — disable invalidates a different network
repository.disable(userWalletId, otherNetwork)
repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList()
// THEN — `network` cache is untouched; probe was called only once (initial fill)
coVerify(exactly = 1) {
walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB)
}
}
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
}
private companion object {
const val XPUB = "xpub6-test-value"
const val SUPPORTED_NETWORK_ID = "bitcoin"
const val OTHER_SUPPORTED_NETWORK_ID = "litecoin"
}
}

View file

@ -442,12 +442,8 @@ internal class DefaultWalletManagersFacade @Inject constructor(
)
}
try {
walletManager.enableDynamicAddresses(xpub)
SimpleResult.Success
} catch (e: Exception) {
SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to enable XPUB mode"))
}
walletManager.enableDynamicAddresses(xpub)
SimpleResult.Success
}
@Suppress("TooGenericExceptionCaught")
@ -461,12 +457,8 @@ internal class DefaultWalletManagersFacade @Inject constructor(
)
}
try {
walletManager.disableDynamicAddresses()
SimpleResult.Success
} catch (e: Exception) {
SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to disable XPUB mode"))
}
walletManager.disableDynamicAddresses()
SimpleResult.Success
}
override suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean {
@ -507,6 +499,25 @@ internal class DefaultWalletManagersFacade @Inject constructor(
}
}
override suspend fun probeHasFundsOnAdditionalAddresses(
userWalletId: UserWalletId,
network: Network,
xpub: String,
): Boolean {
return withContext(dispatchers.io) {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
val dynamicAddressesManager = walletManager as? DynamicAddressesManager
?: return@withContext false
when (val result = dynamicAddressesManager.probeHasFundsOnNonBaseAddresses(xpub)) {
is Result.Success -> result.data
is Result.Failure -> {
TangemLogger.w("Xpub probe failed for ${network.id}: ${result.error}")
false
}
}
}
}
private suspend fun getEnabledDynamicAddressesManagerOrNull(
userWalletId: UserWalletId,
network: Network,

View file

@ -25,4 +25,12 @@ interface DynamicAddressesRepository {
/** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */
fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow<Boolean>
/**
* Emits true when dynamic addresses are DISABLED for this token but a silent xpub probe
* detected non-zero balances on derived addresses beyond the base one. Only positive
* results are cached per session; false results and probe failures are not cached and may
* be re-probed on subsequent collections or when status changes.
*/
fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow<Boolean>
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.tokens.model.warnings
sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() {
data object FundsFound : DynamicAddressesWarnings()
}

View file

@ -303,5 +303,14 @@ interface WalletManagersFacade {
suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
/**
* Silently probes the xpub for balances on non-base derived addresses.
* Does not mutate wallet manager state; can be called when dynamic addresses mode is disabled.
*
* @return true if any non-base derived address has a non-zero balance, false on probe failure,
* when the network doesn't support dynamic addresses, or when no extra funds were found.
*/
suspend fun probeHasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network, xpub: String): Boolean
// endregion Dynamic Addresses
}

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

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1496"
tangemBlockchainSdk = "develop-1498"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-602"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^