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,