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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue