Updated on 2026-08-14
This commit is contained in:
parent
361bd8168b
commit
3da4771d66
19 changed files with 605 additions and 14 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.domain.tokens.model.warnings
|
||||
|
||||
sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() {
|
||||
|
||||
data object FundsFound : DynamicAddressesWarnings()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
|
|||
is TokenDetailsNotification.MigrationClore,
|
||||
is TokenDetailsNotification.UsedOutdatedData,
|
||||
-> null
|
||||
is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue