Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-06 21:14:08 +03:00
parent 182aa24095
commit 94a9b132a2
23 changed files with 458 additions and 60 deletions

View file

@ -114,6 +114,7 @@ dependencies {
implementation(projects.domain.card) implementation(projects.domain.card)
implementation(projects.domain.demo) implementation(projects.domain.demo)
implementation(projects.domain.demo.models) implementation(projects.domain.demo.models)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.express) implementation(projects.domain.express)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
@ -199,6 +200,7 @@ dependencies {
implementation(projects.data.visa) implementation(projects.data.visa)
implementation(projects.data.promo) implementation(projects.data.promo)
implementation(projects.data.onboarding) implementation(projects.data.onboarding)
implementation(projects.data.dynamicAddresses)
implementation(projects.data.feedback) implementation(projects.data.feedback)
implementation(projects.data.qrScanning) implementation(projects.data.qrScanning)
implementation(projects.data.staking) implementation(projects.data.staking)

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.domain.card.BuildConfig import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
@ -32,6 +33,7 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory, visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles, onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
blockchainToDeriveFinder: BlockchainToDeriveFinder, blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsErrorHandler: AnalyticsErrorHandler, analyticsErrorHandler: AnalyticsErrorHandler,
): TangemSdkManager { ): TangemSdkManager {
@ -45,6 +47,7 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory, visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
blockchainToDeriveFinder = blockchainToDeriveFinder, blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsErrorHandler = analyticsErrorHandler, analyticsErrorHandler = analyticsErrorHandler,
) )

View file

@ -30,6 +30,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.* import com.tangem.domain.visa.model.*
import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.ScanTask import com.tangem.operations.ScanTask
import com.tangem.operations.derivation.DerivationTaskResponse import com.tangem.operations.derivation.DerivationTaskResponse
@ -68,6 +69,7 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsErrorHandler: AnalyticsErrorHandler, private val analyticsErrorHandler: AnalyticsErrorHandler,
) : TangemSdkManager { ) : TangemSdkManager {
@ -142,6 +144,7 @@ internal class DefaultTangemSdkManager(
visaCardScanHandler = visaCardScanHandler, visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this, visaCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
), ),
cardId = cardId, cardId = cardId,
@ -446,7 +449,11 @@ internal class DefaultTangemSdkManager(
initialMessage: Message, initialMessage: Message,
): CompletionResult<ScanResponse> { ): CompletionResult<ScanResponse> {
return runTaskAsync( return runTaskAsync(
runnable = FinalizeTwinTask(secondCardPublicKey, issuerKeyPair), runnable = FinalizeTwinTask(
twinPublicKey = secondCardPublicKey,
issuerKeys = issuerKeyPair,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
),
cardId = cardId, cardId = cardId,
initialMessage = initialMessage, initialMessage = initialMessage,
preflightReadFilter = null, preflightReadFilter = null,

View file

@ -44,6 +44,7 @@ import com.tangem.tap.store
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class ScanProductTask( internal class ScanProductTask(
private val card: Card?, private val card: Card?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
@ -51,6 +52,7 @@ internal class ScanProductTask(
private val visaCoroutineScope: CoroutineScope?, private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean, private val shouldCheckIsAlreadyActivated: Boolean,
private val isDynamicAddressesEnabled: Boolean,
override val allowsRequestAccessCodeFromRepository: Boolean = false, override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> { ) : CardSessionRunnable<ScanResponse> {
@ -78,7 +80,7 @@ internal class ScanProductTask(
readVisaCard( readVisaCard(
session = session, session = session,
cardDto = cardDto, cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder), scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled),
callback = callback, callback = callback,
) )
return return
@ -86,7 +88,7 @@ internal class ScanProductTask(
val commandProcessor = when { val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor() cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(blockchainToDeriveFinder) else -> ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled)
} }
commandProcessor.proceed(cardDto, session) { processorResult -> commandProcessor.proceed(cardDto, session) { processorResult ->
when (processorResult) { when (processorResult) {
@ -168,6 +170,7 @@ internal class ScanProductTask(
private class ScanWalletProcessor( private class ScanWalletProcessor(
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val isDynamicAddressesEnabled: Boolean,
) : ProductCommandProcessor<ScanResponse> { ) : ProductCommandProcessor<ScanResponse> {
var primaryCard: PrimaryCard? = null var primaryCard: PrimaryCard? = null
@ -333,7 +336,7 @@ private class ScanWalletProcessor(
?.find(card) ?.find(card)
?: return emptyMap() ?: return emptyMap()
return MissedDerivationsFinder(scanResponse).findByBlockchainsToDerive(blockchains) return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains)
} }
} }

View file

@ -12,6 +12,7 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask
class FinalizeTwinTask( class FinalizeTwinTask(
private val twinPublicKey: ByteArray, private val twinPublicKey: ByteArray,
private val issuerKeys: KeyPair, private val issuerKeys: KeyPair,
private val isDynamicAddressesEnabled: Boolean,
) : CardSessionRunnable<ScanResponse> { ) : CardSessionRunnable<ScanResponse> {
override val allowsRequestAccessCodeFromRepository: Boolean = false override val allowsRequestAccessCodeFromRepository: Boolean = false
@ -32,6 +33,7 @@ class FinalizeTwinTask(
visaCardScanHandler = null, visaCardScanHandler = null,
visaCoroutineScope = null, visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false, shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = false,
onboardingV2FeatureToggles = null, onboardingV2FeatureToggles = null,
).run(session, callback) ).run(session, callback)
is CompletionResult.Failure -> is CompletionResult.Failure ->

View file

@ -32,6 +32,7 @@ data class UserTokensResponse(
@Json(name = "decimals") val decimals: Int, @Json(name = "decimals") val decimals: Int,
@Json(name = "contractAddress") val contractAddress: String?, @Json(name = "contractAddress") val contractAddress: String?,
@Json(name = "addresses") val addresses: List<String>? = null, @Json(name = "addresses") val addresses: List<String>? = null,
@Json(name = "dynamicAddressesEnabled") val dynamicAddressesEnabled: Boolean? = null,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
val otherToken = other as? Token ?: return false val otherToken = other as? Token ?: return false

View file

@ -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
}

View file

@ -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<Throwable, TransactionData> = 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")
}
}

View file

@ -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)
}

View file

@ -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<DynamicAddressesStatus> {
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
}
}

View file

@ -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,
)
}
}

View file

@ -6,6 +6,7 @@ import arrow.core.raise.ensureNotNull
import arrow.core.right import arrow.core.right
import com.tangem.blockchain.blockchains.solana.RentProvider import com.tangem.blockchain.blockchains.solana.RentProvider
import com.tangem.blockchain.common.* import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.DynamicAddressesManager
import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchain.common.address.EstimationFeeAddressFactory 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") @Deprecated("Will be removed in future")
override suspend fun getOrCreateWalletManager(userWalletId: UserWalletId, network: Network): WalletManager? { override suspend fun getOrCreateWalletManager(userWalletId: UserWalletId, network: Network): WalletManager? {
val blockchain = network.toBlockchain() val blockchain = network.toBlockchain()
@ -825,4 +905,9 @@ internal class DefaultWalletManagersFacade @Inject constructor(
private suspend fun readSmartContractMethods(): Map<String, SmartContractMethod> { private suspend fun readSmartContractMethods(): Map<String, SmartContractMethod> {
return assetLoader.loadMap<SmartContractMethod>(fileName = "contract_methods") return assetLoader.loadMap<SmartContractMethod>(fileName = "contract_methods")
} }
private companion object {
const val XPUB_PATH_MIN_NODES = 2
const val RECEIVE_CHAIN_INDEX = 0L
}
} }

View file

@ -31,6 +31,7 @@ dependencies {
/** Domain */ /** Domain */
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.card) implementation(projects.domain.card)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)

View file

@ -11,6 +11,7 @@ import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.network.NetworkFactory import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.derivations.Derivations import com.tangem.data.wallets.derivations.Derivations
import com.tangem.data.wallets.derivations.MissedDerivationsFinder 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.currency.CryptoCurrency
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
@ -30,6 +31,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor(
private val tangemSdkManager: TangemSdkManager, private val tangemSdkManager: TangemSdkManager,
private val networkFactory: NetworkFactory, private val networkFactory: NetworkFactory,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) : ColdMapDerivationsRepository { ) : ColdMapDerivationsRepository {
override suspend fun derivePublicKeys( override suspend fun derivePublicKeys(
@ -64,7 +66,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor(
return@withContext userWallet return@withContext userWallet
} }
val derivations = MissedDerivationsFinder(userWallet) val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled)
.findByNetworks(networks) .findByNetworks(networks)
.ifEmpty { .ifEmpty {
TangemLogger.d("Nothing to derive") TangemLogger.d("Nothing to derive")
@ -103,7 +105,7 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor(
networksWithDerivationPath: Map<BackendId, String?>, networksWithDerivationPath: Map<BackendId, String?>,
): Boolean = withContext(dispatchers.io) { ): Boolean = withContext(dispatchers.io) {
val derivations = val derivations =
MissedDerivationsFinder(userWallet) MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled)
.findByNetworks( .findByNetworks(
networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) -> networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) ->
networkFactory.create( networkFactory.create(

View file

@ -30,17 +30,20 @@ data class BlockchainToDerive(
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
class MissedDerivationsFinder private constructor(private val source: DerivationsSource) { class MissedDerivationsFinder private constructor(
private val source: DerivationsSource,
private val isDynamicAddressesEnabled: Boolean,
) {
/** constructor(userWallet: UserWallet, isDynamicAddressesEnabled: Boolean) : this(
* Secondary constructor for backward compatibility with UserWallet source = DerivationsSource.FromUserWallet(userWallet),
*/ isDynamicAddressesEnabled = isDynamicAddressesEnabled,
constructor(userWallet: UserWallet) : this(DerivationsSource.FromUserWallet(userWallet)) )
/** constructor(scanResponse: ScanResponse, isDynamicAddressesEnabled: Boolean) : this(
* Secondary constructor for ScanResponse source = DerivationsSource.FromScanResponse(scanResponse),
*/ isDynamicAddressesEnabled = isDynamicAddressesEnabled,
constructor(scanResponse: ScanResponse) : this(DerivationsSource.FromScanResponse(scanResponse)) )
/** Find missed derivations for given currencies [currencies] */ /** Find missed derivations for given currencies [currencies] */
fun find(currencies: List<CryptoCurrency>): Derivations { fun find(currencies: List<CryptoCurrency>): Derivations {
@ -115,6 +118,9 @@ class MissedDerivationsFinder private constructor(private val source: Derivation
// Extended Cardano derivation path if needed // Extended Cardano derivation path if needed
add(getCardanoExtendedDerivationPath(derivationPath)) add(getCardanoExtendedDerivationPath(derivationPath))
// Account-level and parent paths for XPUB generation (Dynamic Addresses)
addAll(getXpubDerivationPaths(derivationPath))
} }
.filterNotNull() .filterNotNull()
.distinct() .distinct()
@ -129,6 +135,26 @@ class MissedDerivationsFinder private constructor(private val source: Derivation
return CardanoUtils.extendedDerivationPath(derivationPath = customDerivationPath) 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<DerivationPath> {
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<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> { private fun List<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList() val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList()
return filterNot(alreadyDerivedPaths::contains) return filterNot(alreadyDerivedPaths::contains)
@ -161,4 +187,10 @@ class MissedDerivationsFinder private constructor(private val source: Derivation
} }
// endregion // endregion
private companion object {
const val XPUB_MIN_NODES = 3
const val XPUB_ACCOUNT_NODE_COUNT = 3
const val XPUB_PARENT_NODE_COUNT = 2
}
} }

View file

@ -8,6 +8,7 @@ import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.network.NetworkFactory import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.derivations.MissedDerivationsFinder import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
@ -28,6 +29,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
private val networkFactory: NetworkFactory, private val networkFactory: NetworkFactory,
private val hotWalletAccessor: HotWalletAccessor, private val hotWalletAccessor: HotWalletAccessor,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) : HotMapDerivationsRepository { ) : HotMapDerivationsRepository {
override suspend fun derivePublicKeys( override suspend fun derivePublicKeys(
@ -59,7 +61,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
userWallet: UserWallet.Hot, userWallet: UserWallet.Hot,
networks: List<Network>, networks: List<Network>,
): UserWallet.Hot = withContext(dispatchers.default) { ): UserWallet.Hot = withContext(dispatchers.default) {
val derivations = MissedDerivationsFinder(userWallet) val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled)
.findByNetworks(networks) .findByNetworks(networks)
.ifEmpty { .ifEmpty {
TangemLogger.d("Nothing to derive") TangemLogger.d("Nothing to derive")
@ -103,7 +105,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
userWallet: UserWallet.Hot, userWallet: UserWallet.Hot,
networksWithDerivationPath: Map<BackendId, String?>, networksWithDerivationPath: Map<BackendId, String?>,
): Boolean = withContext(dispatchers.default) { ): Boolean = withContext(dispatchers.default) {
val derivations = MissedDerivationsFinder(userWallet) val derivations = MissedDerivationsFinder(userWallet, dynamicAddressesFeatureToggles.isDynamicAddressesEnabled)
.findByNetworks( .findByNetworks(
networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) -> networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) ->
networkFactory.create( networkFactory.create(

View file

@ -27,7 +27,7 @@ internal class MissedDerivationsFinderTest {
fun `empty derivations for empty currencies`() { fun `empty derivations for empty currencies`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val actual = finder.find(emptyList()) val actual = finder.find(emptyList())
@ -39,7 +39,7 @@ internal class MissedDerivationsFinderTest {
// Bls is not supported // Bls is not supported
val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).chia.let(::listOf) val currencies = MockCryptoCurrencyFactory(userWallet).chia.let(::listOf)
val actual = finder.find(currencies) val actual = finder.find(currencies)
@ -61,7 +61,7 @@ internal class MissedDerivationsFinderTest {
) )
} }
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).chiaAndEthereum val currencies = MockCryptoCurrencyFactory(userWallet).chiaAndEthereum
val actual = finder.find(currencies) val actual = finder.find(currencies)
@ -76,7 +76,7 @@ internal class MissedDerivationsFinderTest {
fun `derivations for custom token`() { fun `derivations for custom token`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereumTokenWithBinanceDerivation val currencies = MockCryptoCurrencyFactory(userWallet).ethereumTokenWithBinanceDerivation
val actual = finder.find(currencies) val actual = finder.find(currencies)
@ -94,7 +94,7 @@ internal class MissedDerivationsFinderTest {
fun `derivations for cardano`() { fun `derivations for cardano`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf) val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf)
val actual = finder.find(currencies) val actual = finder.find(currencies)
@ -124,7 +124,7 @@ internal class MissedDerivationsFinderTest {
derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, derivedKeys = DerivedKeysMocks.ethereumDerivedKeys,
) )
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf) val currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf)
val actual = finder.find(currencies) val actual = finder.find(currencies)
@ -139,7 +139,7 @@ internal class MissedDerivationsFinderTest {
derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, derivedKeys = DerivedKeysMocks.ethereumDerivedKeys,
) )
val userWallet = MockUserWalletFactory.create(scanResponse) val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet) val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereumAndStellar val currencies = MockCryptoCurrencyFactory(userWallet).ethereumAndStellar
val actual = finder.find(currencies) val actual = finder.find(currencies)

View file

@ -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,
)

View file

@ -1,16 +1,16 @@
package com.tangem.domain.dynamicaddresses package com.tangem.domain.dynamicaddresses
import arrow.core.Either 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.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
class GetConsolidationInfoUseCase( class CreateConsolidationTransactionUseCase(
private val consolidationRepository: ConsolidationRepository, private val consolidationRepository: ConsolidationRepository,
) { ) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, ConsolidationInfo> { suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, TransactionData> {
return consolidationRepository.getConsolidationInfo(userWalletId, network) return consolidationRepository.createConsolidationTransaction(userWalletId, network)
} }
} }

View file

@ -1,33 +1,27 @@
package com.tangem.domain.dynamicaddresses package com.tangem.domain.dynamicaddresses
import arrow.core.Either import arrow.core.Either
import arrow.core.getOrElse import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import arrow.core.raise.either
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId 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( class DisableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesRepository: DynamicAddressesRepository,
private val consolidationRepository: ConsolidationRepository,
) { ) {
/** /**
* Returns [ConsolidationInfo] when consolidation is required before disabling, * Returns true when consolidation is required before disabling (non-base balances exist),
* or null when DA can be disabled immediately (no non-base balances). * or false when DA was disabled immediately.
*/ */
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, ConsolidationInfo?> = suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
either { Either.catch {
val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network) val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
if (!hasNonBaseBalances) { if (!hasNonBaseBalances) {
dynamicAddressesRepository.disable(userWalletId, network) dynamicAddressesRepository.disable(userWalletId, network)
return@either null return@catch false
} }
consolidationRepository.getConsolidationInfo(userWalletId, network) true
.getOrElse { raise(it) }
} }
} }

View file

@ -1,21 +1,14 @@
package com.tangem.domain.dynamicaddresses.repository package com.tangem.domain.dynamicaddresses.repository
import arrow.core.Either import arrow.core.Either
import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
interface ConsolidationRepository { interface ConsolidationRepository {
suspend fun getConsolidationInfo( suspend fun createConsolidationTransaction(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
): Either<Throwable, ConsolidationInfo> ): Either<Throwable, TransactionData>
suspend fun sendConsolidationTransaction(
userWalletId: UserWalletId,
network: Network,
signer: TransactionSigner,
): Either<Throwable, String>
} }

View file

@ -289,4 +289,18 @@ interface WalletManagersFacade {
* value. Otherwise always return true * value. Otherwise always return true
*/ */
suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean 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
} }

View file

@ -420,6 +420,7 @@ include(":data:payment")
include(":data:virtual-account") include(":data:virtual-account")
include(":data:promo") include(":data:promo")
include(":data:onboarding") include(":data:onboarding")
include(":data:dynamic-addresses")
include(":data:feedback") include(":data:feedback")
include(":data:qr-scanning") include(":data:qr-scanning")
include(":data:staking") include(":data:staking")