diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb907bd129..7c7411dc8a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,7 @@ dependencies { implementation(projects.domain.card) implementation(projects.domain.demo) implementation(projects.domain.demo.models) + implementation(projects.domain.dynamicAddresses) implementation(projects.domain.express) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) @@ -199,6 +200,7 @@ dependencies { implementation(projects.data.visa) implementation(projects.data.promo) implementation(projects.data.onboarding) + implementation(projects.data.dynamicAddresses) implementation(projects.data.feedback) implementation(projects.data.qrScanning) implementation(projects.data.staking) diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 765ac323f2..cca1b670ed 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.domain.card.BuildConfig import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager @@ -32,6 +33,7 @@ internal class TangemSdkManagerModule { visaCardActivationTaskFactory: VisaCardActivationTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, + dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, blockchainToDeriveFinder: BlockchainToDeriveFinder, analyticsErrorHandler: AnalyticsErrorHandler, ): TangemSdkManager { @@ -45,6 +47,7 @@ internal class TangemSdkManagerModule { visaCardActivationTaskFactory = visaCardActivationTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, blockchainToDeriveFinder = blockchainToDeriveFinder, analyticsErrorHandler = analyticsErrorHandler, ) diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 9a0a097c0d..29a181c404 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -30,6 +30,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.* import com.tangem.domain.wallets.derivations.derivationStyleProvider +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.ScanTask import com.tangem.operations.derivation.DerivationTaskResponse @@ -68,6 +69,7 @@ internal class DefaultTangemSdkManager( private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val analyticsErrorHandler: AnalyticsErrorHandler, ) : TangemSdkManager { @@ -142,6 +144,7 @@ internal class DefaultTangemSdkManager( visaCardScanHandler = visaCardScanHandler, visaCoroutineScope = this, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, + isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, onboardingV2FeatureToggles = onboardingV2FeatureToggles, ), cardId = cardId, @@ -446,7 +449,11 @@ internal class DefaultTangemSdkManager( initialMessage: Message, ): CompletionResult { return runTaskAsync( - runnable = FinalizeTwinTask(secondCardPublicKey, issuerKeyPair), + runnable = FinalizeTwinTask( + twinPublicKey = secondCardPublicKey, + issuerKeys = issuerKeyPair, + isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, + ), cardId = cardId, initialMessage = initialMessage, preflightReadFilter = null, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index eb14c89bb4..266bfaa86b 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -44,6 +44,7 @@ import com.tangem.tap.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +@Suppress("LongParameterList") internal class ScanProductTask( private val card: Card?, private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, @@ -51,6 +52,7 @@ internal class ScanProductTask( private val visaCoroutineScope: CoroutineScope?, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val shouldCheckIsAlreadyActivated: Boolean, + private val isDynamicAddressesEnabled: Boolean, override val allowsRequestAccessCodeFromRepository: Boolean = false, ) : CardSessionRunnable { @@ -78,7 +80,7 @@ internal class ScanProductTask( readVisaCard( session = session, cardDto = cardDto, - scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder), + scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled), callback = callback, ) return @@ -86,7 +88,7 @@ internal class ScanProductTask( val commandProcessor = when { cardDto.isTangemTwins -> ScanTwinProcessor() - else -> ScanWalletProcessor(blockchainToDeriveFinder) + else -> ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled) } commandProcessor.proceed(cardDto, session) { processorResult -> when (processorResult) { @@ -168,6 +170,7 @@ internal class ScanProductTask( private class ScanWalletProcessor( private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, + private val isDynamicAddressesEnabled: Boolean, ) : ProductCommandProcessor { var primaryCard: PrimaryCard? = null @@ -333,7 +336,7 @@ private class ScanWalletProcessor( ?.find(card) ?: return emptyMap() - return MissedDerivationsFinder(scanResponse).findByBlockchainsToDerive(blockchains) + return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains) } } diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index c851c06bdf..cc707e1fce 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -12,6 +12,7 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask class FinalizeTwinTask( private val twinPublicKey: ByteArray, private val issuerKeys: KeyPair, + private val isDynamicAddressesEnabled: Boolean, ) : CardSessionRunnable { override val allowsRequestAccessCodeFromRepository: Boolean = false @@ -32,6 +33,7 @@ class FinalizeTwinTask( visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, + isDynamicAddressesEnabled = false, onboardingV2FeatureToggles = null, ).run(session, callback) is CompletionResult.Failure -> diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c0edc1becf..4fe788204a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -32,6 +32,7 @@ data class UserTokensResponse( @Json(name = "decimals") val decimals: Int, @Json(name = "contractAddress") val contractAddress: String?, @Json(name = "addresses") val addresses: List? = null, + @Json(name = "dynamicAddressesEnabled") val dynamicAddressesEnabled: Boolean? = null, ) { override fun equals(other: Any?): Boolean { val otherToken = other as? Token ?: return false diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts new file mode 100644 index 0000000000..47a15f3e68 --- /dev/null +++ b/data/dynamic-addresses/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.dynamicaddresses" +} + +dependencies { + // region Project - Core + implementation(projects.core.configToggles) + implementation(projects.core.datasource) + implementation(projects.core.utils) + // endregion + + // region Project - Data + implementation(projects.data.common) + // endregion + + // region Project - Domain + implementation(projects.domain.account) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) + implementation(projects.domain.models) + implementation(projects.domain.walletManager) + // endregion + + // region Project - Libs + implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion +} \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt new file mode 100644 index 0000000000..0860a7b9ce --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt @@ -0,0 +1,45 @@ +package com.tangem.data.dynamicaddresses + +import arrow.core.Either +import com.tangem.blockchain.common.DynamicAddressesManager +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository +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 kotlinx.coroutines.withContext + +internal class DefaultConsolidationRepository( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : ConsolidationRepository { + + override suspend fun createConsolidationTransaction( + userWalletId: UserWalletId, + network: Network, + ): Either = Either.catch { + withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + ?: error("WalletManager not found") + + val dynamicAddressesManager = walletManager as? DynamicAddressesManager + ?: error("WalletManager does not support dynamic addresses") + + val fee = getNormalFee(walletManager) + + when (val result = dynamicAddressesManager.createConsolidationTransaction(fee)) { + is Result.Success -> result.data + is Result.Failure -> error("Failed to create consolidation tx: ${result.error}") + } + } + } + + @Suppress("UnusedParameter") + private fun getNormalFee(walletManager: WalletManager): Fee { + TODO("Fee calculation for consolidation from multiple addresses is not yet implemented") + } +} \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesFeatureToggles.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesFeatureToggles.kt new file mode 100644 index 0000000000..83680d2320 --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.data.dynamicaddresses + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles + +internal class DefaultDynamicAddressesFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : DynamicAddressesFeatureToggles { + + override val isDynamicAddressesEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.DYNAMIC_ADDRESSES_ENABLED) +} \ 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 new file mode 100644 index 0000000000..28c3c017b6 --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -0,0 +1,106 @@ +package com.tangem.data.dynamicaddresses + +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +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.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +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 com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +internal class DefaultDynamicAddressesRepository( + private val walletAccountsFetcher: WalletAccountsFetcher, + private val walletAccountsSaver: WalletAccountsSaver, + private val accountsCRUDRepository: AccountsCRUDRepository, + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : DynamicAddressesRepository { + + override fun getStatus(userWalletId: UserWalletId, network: Network): Flow { + return walletAccountsFetcher.get(userWalletId) + .map { response -> + val token = response.findToken(network) + when { + token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED + else -> DynamicAddressesStatus.DISABLED + } + // TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally + } + .flowOn(dispatchers.io) + } + + override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) { + withContext(dispatchers.io) { + walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) + runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } + .onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } + } + } + + override suspend fun disable(userWalletId: UserWalletId, network: Network) { + withContext(dispatchers.io) { + walletManagersFacade.disableXpubMode(userWalletId, network) + updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) + runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } + .onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } + } + } + + override suspend fun getReceiveAddress(userWalletId: UserWalletId, network: Network): String { + return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) + ?: error("Dynamic receive address not available for $userWalletId / ${network.id}") + } + + override suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? { + return walletManagersFacade.getDynamicAddressesLastUsedReceiveAddress(userWalletId, network) + } + + override suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean { + return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network) + } + + private suspend fun updateTokenDynamicAddressesFlag( + userWalletId: UserWalletId, + network: Network, + enabled: Boolean, + ) { + walletAccountsSaver.update(userWalletId) { response -> + response?.copy( + accounts = response.accounts.map { account -> + account.copy( + tokens = account.tokens?.map { token -> + if (token.matchesNetwork(network)) { + token.copy(dynamicAddressesEnabled = enabled) + } else { + token + } + }, + ) + }, + ) + } + } + + private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { + return accounts + .flatMap { it.tokens.orEmpty() } + .find { it.matchesNetwork(network) } + } + + private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean { + return networkId == network.backendId && + derivationPath == network.derivationPath.value && + contractAddress == null + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..8ace9bf67b --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt @@ -0,0 +1,62 @@ +package com.tangem.data.dynamicaddresses.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.data.dynamicaddresses.DefaultConsolidationRepository +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.repository.ConsolidationRepository +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DynamicAddressesDataModule { + + @Provides + @Singleton + fun provideDynamicAddressesRepository( + walletAccountsFetcher: WalletAccountsFetcher, + walletAccountsSaver: WalletAccountsSaver, + accountsCRUDRepository: AccountsCRUDRepository, + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): DynamicAddressesRepository { + return DefaultDynamicAddressesRepository( + walletAccountsFetcher = walletAccountsFetcher, + walletAccountsSaver = walletAccountsSaver, + accountsCRUDRepository = accountsCRUDRepository, + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideDynamicAddressesFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): DynamicAddressesFeatureToggles { + return DefaultDynamicAddressesFeatureToggles(featureTogglesManager = featureTogglesManager) + } + + @Provides + @Singleton + fun provideConsolidationRepository( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): ConsolidationRepository { + return DefaultConsolidationRepository( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } +} \ 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 68f31b8849..117bfacb59 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 @@ -6,6 +6,7 @@ import arrow.core.raise.ensureNotNull import arrow.core.right import com.tangem.blockchain.blockchains.solana.RentProvider import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.DynamicAddressesManager import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.EstimationFeeAddressFactory @@ -422,6 +423,85 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + // region Dynamic Addresses + + @Suppress("TooGenericExceptionCaught") + override suspend fun enableXpubMode(userWalletId: UserWalletId, network: Network, xpub: String): SimpleResult = + withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + + if (walletManager !is DynamicAddressesManager) { + return@withContext SimpleResult.Failure( + BlockchainSdkError.CustomError("WalletManager does not support dynamic addresses"), + ) + } + + try { + walletManager.enableDynamicAddresses(xpub) + SimpleResult.Success + } catch (e: Exception) { + SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to enable XPUB mode")) + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun disableXpubMode(userWalletId: UserWalletId, network: Network): SimpleResult = + withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + + if (walletManager !is DynamicAddressesManager) { + return@withContext SimpleResult.Failure( + BlockchainSdkError.CustomError("WalletManager does not support dynamic addresses"), + ) + } + + try { + walletManager.disableDynamicAddresses() + SimpleResult.Success + } catch (e: Exception) { + SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to disable XPUB mode")) + } + } + + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address + } + + override suspend fun getDynamicAddressesLastUsedReceiveAddress( + userWalletId: UserWalletId, + network: Network, + ): String? { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + return dynamicAddressesManager.usedAddresses + .filter { usedAddress -> + val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() + ?: return@filter false + nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX + } + .maxByOrNull { usedAddress -> + runCatching { DerivationPath(usedAddress.path).nodes.last().index }.getOrDefault(0L) + } + ?.address + } + + override suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return false + return dynamicAddressesManager.usedAddresses.any { usedAddress -> + val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() + ?: return@any false + val isBaseAddress = nodes.size >= XPUB_PATH_MIN_NODES && + nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX && + nodes.last().index == 0L + !isBaseAddress && usedAddress.balance > BigDecimal.ZERO + } + } + + // endregion Dynamic Addresses + @Deprecated("Will be removed in future") override suspend fun getOrCreateWalletManager(userWalletId: UserWalletId, network: Network): WalletManager? { val blockchain = network.toBlockchain() @@ -825,4 +905,9 @@ internal class DefaultWalletManagersFacade @Inject constructor( private suspend fun readSmartContractMethods(): Map { return assetLoader.loadMap(fileName = "contract_methods") } + + private companion object { + const val XPUB_PATH_MIN_NODES = 2 + const val RECEIVE_CHAIN_INDEX = 0L + } } \ No newline at end of file diff --git a/data/wallets/build.gradle.kts b/data/wallets/build.gradle.kts index 19a6351a4c..ed6a784a20 100644 --- a/data/wallets/build.gradle.kts +++ b/data/wallets/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { /** Domain */ implementation(projects.domain.account) implementation(projects.domain.card) + implementation(projects.domain.dynamicAddresses) implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt index f3305bfb5e..b141b3b5ca 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.network.NetworkFactory import com.tangem.data.wallets.derivations.Derivations import com.tangem.data.wallets.derivations.MissedDerivationsFinder +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse @@ -30,6 +31,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val networkFactory: NetworkFactory, private val dispatchers: CoroutineDispatcherProvider, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : ColdMapDerivationsRepository { override suspend fun derivePublicKeys( @@ -64,7 +66,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( return@withContext userWallet } - val derivations = MissedDerivationsFinder(userWallet) + val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) .findByNetworks(networks) .ifEmpty { TangemLogger.d("Nothing to derive") @@ -103,7 +105,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( networksWithDerivationPath: Map, ): Boolean = withContext(dispatchers.io) { val derivations = - MissedDerivationsFinder(userWallet) + MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) .findByNetworks( networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) -> networkFactory.create( diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt index 3ea84acd9e..9c4c0af642 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt @@ -30,17 +30,20 @@ data class BlockchainToDerive( * [REDACTED_AUTHOR] */ -class MissedDerivationsFinder private constructor(private val source: DerivationsSource) { +class MissedDerivationsFinder private constructor( + private val source: DerivationsSource, + private val isDynamicAddressesEnabled: Boolean, +) { - /** - * Secondary constructor for backward compatibility with UserWallet - */ - constructor(userWallet: UserWallet) : this(DerivationsSource.FromUserWallet(userWallet)) + constructor(userWallet: UserWallet, isDynamicAddressesEnabled: Boolean) : this( + source = DerivationsSource.FromUserWallet(userWallet), + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + ) - /** - * Secondary constructor for ScanResponse - */ - constructor(scanResponse: ScanResponse) : this(DerivationsSource.FromScanResponse(scanResponse)) + constructor(scanResponse: ScanResponse, isDynamicAddressesEnabled: Boolean) : this( + source = DerivationsSource.FromScanResponse(scanResponse), + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + ) /** Find missed derivations for given currencies [currencies] */ fun find(currencies: List): Derivations { @@ -115,6 +118,9 @@ class MissedDerivationsFinder private constructor(private val source: Derivation // Extended Cardano derivation path if needed add(getCardanoExtendedDerivationPath(derivationPath)) + + // Account-level and parent paths for XPUB generation (Dynamic Addresses) + addAll(getXpubDerivationPaths(derivationPath)) } .filterNotNull() .distinct() @@ -129,6 +135,26 @@ class MissedDerivationsFinder private constructor(private val source: Derivation return CardanoUtils.extendedDerivationPath(derivationPath = customDerivationPath) } + /** + * Returns account-level and parent derivation paths needed for XPUB generation. + * - Account-level (e.g. m/84'/0'/0') — the XPUB itself + * - Parent (e.g. m/84'/0') — needed for parent fingerprint in XPUB serialization + * + * Only applicable for BIP44-style XPUB blockchains (BTC, BCH, LTC, DOGE, DASH, RVN). + */ + private fun Blockchain.getXpubDerivationPaths(derivationPath: DerivationPath): List { + if (!isDynamicAddressesEnabled) return emptyList() + if (!isBip44DerivationStyleXPUB()) return emptyList() + + val nodes = derivationPath.nodes + if (nodes.size < XPUB_MIN_NODES) return emptyList() + + val accountPath = DerivationPath(nodes.take(XPUB_ACCOUNT_NODE_COUNT)) + val parentPath = DerivationPath(nodes.take(XPUB_PARENT_NODE_COUNT)) + + return listOf(accountPath, parentPath) + } + private fun List.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List { val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList() return filterNot(alreadyDerivedPaths::contains) @@ -161,4 +187,10 @@ class MissedDerivationsFinder private constructor(private val source: Derivation } // endregion + + private companion object { + const val XPUB_MIN_NODES = 3 + const val XPUB_ACCOUNT_NODE_COUNT = 3 + const val XPUB_PARENT_NODE_COUNT = 2 + } } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index bcf3ef947c..3e0a758cab 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -8,6 +8,7 @@ import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.network.NetworkFactory import com.tangem.data.wallets.derivations.MissedDerivationsFinder import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency @@ -28,6 +29,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( private val networkFactory: NetworkFactory, private val hotWalletAccessor: HotWalletAccessor, private val dispatchers: CoroutineDispatcherProvider, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : HotMapDerivationsRepository { override suspend fun derivePublicKeys( @@ -59,7 +61,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( userWallet: UserWallet.Hot, networks: List, ): UserWallet.Hot = withContext(dispatchers.default) { - val derivations = MissedDerivationsFinder(userWallet) + val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) .findByNetworks(networks) .ifEmpty { TangemLogger.d("Nothing to derive") @@ -103,7 +105,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( userWallet: UserWallet.Hot, networksWithDerivationPath: Map, ): Boolean = withContext(dispatchers.default) { - val derivations = MissedDerivationsFinder(userWallet) + val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) .findByNetworks( networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) -> networkFactory.create( diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt index cd22c4c1f8..b0285167fc 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt @@ -27,7 +27,7 @@ internal class MissedDerivationsFinderTest { fun `empty derivations for empty currencies`() { val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val actual = finder.find(emptyList()) @@ -39,7 +39,7 @@ internal class MissedDerivationsFinderTest { // Bls is not supported val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).chia.let(::listOf) val actual = finder.find(currencies) @@ -61,7 +61,7 @@ internal class MissedDerivationsFinderTest { ) } val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).chiaAndEthereum val actual = finder.find(currencies) @@ -76,7 +76,7 @@ internal class MissedDerivationsFinderTest { fun `derivations for custom token`() { val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).ethereumTokenWithBinanceDerivation val actual = finder.find(currencies) @@ -94,7 +94,7 @@ internal class MissedDerivationsFinderTest { fun `derivations for cardano`() { val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf) val actual = finder.find(currencies) @@ -124,7 +124,7 @@ internal class MissedDerivationsFinderTest { derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, ) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf) val actual = finder.find(currencies) @@ -139,7 +139,7 @@ internal class MissedDerivationsFinderTest { derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, ) val userWallet = MockUserWalletFactory.create(scanResponse) - val finder = MissedDerivationsFinder(userWallet) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) val currencies = MockCryptoCurrencyFactory(userWallet).ethereumAndStellar val actual = finder.find(currencies) diff --git a/domain/dynamic-addresses/models/src/main/kotlin/com/tangem/domain/dynamicaddresses/model/ConsolidationInfo.kt b/domain/dynamic-addresses/models/src/main/kotlin/com/tangem/domain/dynamicaddresses/model/ConsolidationInfo.kt deleted file mode 100644 index 47c36bd8f9..0000000000 --- a/domain/dynamic-addresses/models/src/main/kotlin/com/tangem/domain/dynamicaddresses/model/ConsolidationInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.domain.dynamicaddresses.model - -import java.math.BigDecimal - -data class ConsolidationInfo( - val fee: BigDecimal, - val inputCount: Int, - val canCoverFee: Boolean, -) \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetConsolidationInfoUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/CreateConsolidationTransactionUseCase.kt similarity index 61% rename from domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetConsolidationInfoUseCase.kt rename to domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/CreateConsolidationTransactionUseCase.kt index a498b28cc7..4a9c25f53f 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetConsolidationInfoUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/CreateConsolidationTransactionUseCase.kt @@ -1,16 +1,16 @@ package com.tangem.domain.dynamicaddresses import arrow.core.Either -import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo +import com.tangem.blockchain.common.TransactionData import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -class GetConsolidationInfoUseCase( +class CreateConsolidationTransactionUseCase( private val consolidationRepository: ConsolidationRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { - return consolidationRepository.getConsolidationInfo(userWalletId, network) + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { + return consolidationRepository.createConsolidationTransaction(userWalletId, network) } } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt index ff9e869754..214c7ee0b7 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt @@ -1,33 +1,27 @@ package com.tangem.domain.dynamicaddresses import arrow.core.Either -import arrow.core.getOrElse -import arrow.core.raise.either +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo -import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository -import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository class DisableDynamicAddressesUseCase( private val dynamicAddressesRepository: DynamicAddressesRepository, - private val consolidationRepository: ConsolidationRepository, ) { /** - * Returns [ConsolidationInfo] when consolidation is required before disabling, - * or null when DA can be disabled immediately (no non-base balances). + * Returns true when consolidation is required before disabling (non-base balances exist), + * or false when DA was disabled immediately. */ - suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either = - either { + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either = + Either.catch { val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network) if (!hasNonBaseBalances) { dynamicAddressesRepository.disable(userWalletId, network) - return@either null + return@catch false } - consolidationRepository.getConsolidationInfo(userWalletId, network) - .getOrElse { raise(it) } + true } } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/ConsolidationRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/ConsolidationRepository.kt index 4eaa27eb38..81689ade89 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/ConsolidationRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/ConsolidationRepository.kt @@ -1,21 +1,14 @@ package com.tangem.domain.dynamicaddresses.repository import arrow.core.Either -import com.tangem.blockchain.common.TransactionSigner -import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo +import com.tangem.blockchain.common.TransactionData import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId interface ConsolidationRepository { - suspend fun getConsolidationInfo( + suspend fun createConsolidationTransaction( userWalletId: UserWalletId, network: Network, - ): Either - - suspend fun sendConsolidationTransaction( - userWalletId: UserWalletId, - network: Network, - signer: TransactionSigner, - ): Either + ): Either } \ 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 3e193f5c8b..04112d2530 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 @@ -289,4 +289,18 @@ interface WalletManagersFacade { * value. Otherwise always return true */ suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean + + // region Dynamic Addresses + + suspend fun enableXpubMode(userWalletId: UserWalletId, network: Network, xpub: String): SimpleResult + + suspend fun disableXpubMode(userWalletId: UserWalletId, network: Network): SimpleResult + + suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? + + suspend fun getDynamicAddressesLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? + + suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + + // endregion Dynamic Addresses } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6131532e14..9d4a5fcb46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -420,6 +420,7 @@ include(":data:payment") include(":data:virtual-account") include(":data:promo") include(":data:onboarding") +include(":data:dynamic-addresses") include(":data:feedback") include(":data:qr-scanning") include(":data:staking")