diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts index a504298e11..0c1a34ea9b 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.data.dynamicaddresses" } +tasks.withType().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 } \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 9f36682a75..7342b9439d 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -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, Boolean>() + private val extraFundsProbeMutex = Mutex() + override fun getStatus(userWalletId: UserWalletId, network: Network): Flow { 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 { + 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 diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt index 8ace9bf67b..141f6a756e 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt @@ -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, ) } diff --git a/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt new file mode 100644 index 0000000000..4202bd889b --- /dev/null +++ b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt @@ -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(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" + } +} \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 3401f5d920..3a0617f596 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -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, diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index 8cdf4b0c12..46f50aadd6 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -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 + + /** + * 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 } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt new file mode 100644 index 0000000000..9eb298cf27 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.tokens.model.warnings + +sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() { + + data object FundsFound : DynamicAddressesWarnings() +} \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 854c807db6..b1f17fb70a 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -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 } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt index 1c8370811a..15664feeef 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt @@ -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) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index d65921fdb9..ba37caff40 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.MigrationClore, is TokenDetailsNotification.UsedOutdatedData, -> null + is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 81e1b3a3d0..5518cfdedb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -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 */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 89fba14379..b8b37a945e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index b8f740b9fe..bb05ee4cc5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -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, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index 802ec6e0bb..669c4a05df 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -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, + ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt index a8ecb28437..40232ef9c8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -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, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt new file mode 100644 index 0000000000..5fdf8f5045 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt @@ -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()) } returns none() + every { accountStatusList.getCryptoCurrencyStatus(any()) } returns none() + } + + every { singleAccountStatusListSupplier(any()) } 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 + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt new file mode 100644 index 0000000000..059dacd1a9 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt @@ -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() } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt index 3cd6df9608..add72c4956 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt @@ -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 diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 91a743b450..122418a100 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -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 ^