diff --git a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt
new file mode 100644
index 0000000000..3b9ec07528
--- /dev/null
+++ b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt
@@ -0,0 +1,78 @@
+package com.tangem.tap.di.domain
+
+import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
+import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
+import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
+import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase
+import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
+import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
+import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
+import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
+import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
+import com.tangem.domain.walletmanager.WalletManagersFacade
+import com.tangem.domain.wallets.derivations.DerivationsRepository
+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 DynamicAddressesDomainModule {
+
+ @Provides
+ @Singleton
+ fun provideEnableDynamicAddressesUseCase(
+ dynamicAddressesRepository: DynamicAddressesRepository,
+ ): EnableDynamicAddressesUseCase {
+ return EnableDynamicAddressesUseCase(dynamicAddressesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDisableDynamicAddressesUseCase(
+ dynamicAddressesRepository: DynamicAddressesRepository,
+ ): DisableDynamicAddressesUseCase {
+ return DisableDynamicAddressesUseCase(dynamicAddressesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetDynamicAddressesStatusUseCase(
+ dynamicAddressesRepository: DynamicAddressesRepository,
+ ): GetDynamicAddressesStatusUseCase {
+ return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetDynamicReceiveAddressUseCase(
+ dynamicAddressesRepository: DynamicAddressesRepository,
+ ): GetDynamicReceiveAddressUseCase {
+ return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideCreateConsolidationTransactionUseCase(
+ consolidationRepository: ConsolidationRepository,
+ ): CreateConsolidationTransactionUseCase {
+ return CreateConsolidationTransactionUseCase(consolidationRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase {
+ return IsXpubSupportedUseCase(walletManagersFacade)
+ }
+
+ @Provides
+ @Singleton
+ fun provideIsXpubDerivedUseCase(
+ walletManagersFacade: WalletManagersFacade,
+ derivationsRepository: DerivationsRepository,
+ ): IsXpubDerivedUseCase {
+ return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository)
+ }
+}
\ No newline at end of file
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 cc707e1fce..548fadc6ad 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
@@ -33,7 +33,7 @@ class FinalizeTwinTask(
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
- isDynamicAddressesEnabled = false,
+ isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
).run(session, callback)
is CompletionResult.Failure ->
diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml
new file mode 100644
index 0000000000..fac7eb89dd
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml
new file mode 100644
index 0000000000..a1e00c61f2
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml
new file mode 100644
index 0000000000..57afa69fb6
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml
new file mode 100644
index 0000000000..71ad60eda8
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts
index 47a15f3e68..a504298e11 100644
--- a/data/dynamic-addresses/build.gradle.kts
+++ b/data/dynamic-addresses/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies {
// region Project - Libs
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
+ implementation(tangemDeps.card.core)
// endregion
// region DI
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
index 28c3c017b6..a9d2bcd8b7 100644
--- 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
@@ -1,5 +1,6 @@
package com.tangem.data.dynamicaddresses
+import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
@@ -10,6 +11,7 @@ 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.blockchain.extensions.SimpleResult
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
@@ -31,17 +33,20 @@ internal class DefaultDynamicAddressesRepository(
.map { response ->
val token = response.findToken(network)
when {
- token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED
- else -> DynamicAddressesStatus.DISABLED
+ token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED
+ !isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP
+ else -> DynamicAddressesStatus.ENABLED
}
- // 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)
+ val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
+ if (result is SimpleResult.Failure) {
+ error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}")
+ }
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) }
@@ -50,7 +55,10 @@ internal class DefaultDynamicAddressesRepository(
override suspend fun disable(userWalletId: UserWalletId, network: Network) {
withContext(dispatchers.io) {
- walletManagersFacade.disableXpubMode(userWalletId, network)
+ val result = walletManagersFacade.disableXpubMode(userWalletId, network)
+ if (result is SimpleResult.Failure) {
+ error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}")
+ }
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) }
@@ -70,6 +78,45 @@ internal class DefaultDynamicAddressesRepository(
return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network)
}
+ override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean {
+ return withContext(dispatchers.io) {
+ val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false
+ val baseDerivationPath = network.derivationPath.value ?: return@withContext false
+
+ response.accounts
+ .flatMap { it.tokens.orEmpty() }
+ .any { token ->
+ val tokenDerivationPath = token.derivationPath ?: return@any false
+ token.networkId == network.backendId &&
+ tokenDerivationPath != baseDerivationPath &&
+ hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath)
+ }
+ }
+ }
+
+ /**
+ * Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account)
+ * as the base path but different change/index nodes (not both 0).
+ */
+ private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean {
+ val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false
+ val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
+
+ if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false
+
+ // First 3 nodes must match (purpose/coin/account) by value, ignoring hardening
+ val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
+ tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
+ }
+ if (!isSameAccount) return false
+
+ // Check if change or index ≠ 0
+ val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
+ val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false)
+
+ return change != 0L || index != 0L
+ }
+
private suspend fun updateTokenDynamicAddressesFlag(
userWalletId: UserWalletId,
network: Network,
@@ -92,6 +139,11 @@ internal class DefaultDynamicAddressesRepository(
}
}
+ private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean {
+ // Check if WalletManager is already in XPUB mode (DA was previously enabled on this device)
+ return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null
+ }
+
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
return accounts
.flatMap { it.tokens.orEmpty() }
@@ -103,4 +155,11 @@ internal class DefaultDynamicAddressesRepository(
derivationPath == network.derivationPath.value &&
contractAddress == null
}
+
+ private companion object {
+ const val DERIVATION_NODE_COUNT = 5
+ const val ACCOUNT_NODE_COUNT = 3
+ const val CHANGE_NODE_INDEX = 3
+ const val INDEX_NODE_INDEX = 4
+ }
}
\ No newline at end of file
diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt
index c347bb1e54..3628a45990 100644
--- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt
+++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt
@@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations
import arrow.core.getOrElse
import com.tangem.common.extensions.ByteArrayKey
+import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
@@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor(
}
}
+ override suspend fun getExistingDerivedKeys(
+ userWalletId: UserWalletId,
+ seedKey: ByteArrayKey,
+ ): ExtendedPublicKeysMap {
+ val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
+ return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap())
+ }
+
+ private fun UserWallet.getExistingDerivedKeys(): Map {
+ return when (this) {
+ is UserWallet.Cold -> scanResponse.derivedKeys
+ is UserWallet.Hot -> wallets
+ ?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) }
+ .orEmpty()
+ }
+ }
+
override suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map,
diff --git a/domain/dynamic-addresses/build.gradle.kts b/domain/dynamic-addresses/build.gradle.kts
index 6190ff052f..d366a99097 100644
--- a/domain/dynamic-addresses/build.gradle.kts
+++ b/domain/dynamic-addresses/build.gradle.kts
@@ -13,8 +13,12 @@ dependencies {
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
+ implementation(projects.domain.walletManager)
+ implementation(projects.domain.wallets)
+ implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
+ implementation(tangemDeps.card.core)
}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt
new file mode 100644
index 0000000000..3e732ca6dc
--- /dev/null
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt
@@ -0,0 +1,53 @@
+package com.tangem.domain.dynamicaddresses
+
+import com.tangem.blockchain.common.Blockchain
+
+/**
+ * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
+ * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
+ *
+ * Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
+ * Only the default derivation style per blockchain is supported.
+ */
+object DynamicAddressesSupportedBlockchains {
+
+ private const val BIP44_PURPOSE = 44L
+ private const val BIP84_PURPOSE = 84L
+
+ private val supported = setOf(
+ Blockchain.Bitcoin,
+ Blockchain.BitcoinTestnet,
+ Blockchain.BitcoinCash,
+ Blockchain.BitcoinCashTestnet,
+ Blockchain.Litecoin,
+ Blockchain.Dogecoin,
+ Blockchain.Dash,
+ Blockchain.Ravencoin,
+ Blockchain.RavencoinTestnet,
+ )
+
+ private val supportedNetworkIds = supported.map { it.id }.toSet()
+
+ /**
+ * Allowed BIP purpose nodes per network ID.
+ * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
+ */
+ private val allowedPurposeByNetworkId: Map = buildMap {
+ put(Blockchain.Bitcoin.id, BIP84_PURPOSE)
+ put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE)
+ put(Blockchain.Litecoin.id, BIP84_PURPOSE)
+ put(Blockchain.BitcoinCash.id, BIP44_PURPOSE)
+ put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE)
+ put(Blockchain.Dogecoin.id, BIP44_PURPOSE)
+ put(Blockchain.Dash.id, BIP44_PURPOSE)
+ put(Blockchain.Ravencoin.id, BIP44_PURPOSE)
+ put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE)
+ }
+
+ fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
+
+ fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
+
+ /** Returns the allowed BIP purpose node for the given network, or null if not supported */
+ fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
+}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt
new file mode 100644
index 0000000000..10d9872222
--- /dev/null
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt
@@ -0,0 +1,8 @@
+package com.tangem.domain.dynamicaddresses
+
+sealed class EnableDynamicAddressesError {
+
+ data object ConflictingCustomTokens : EnableDynamicAddressesError()
+
+ data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError()
+}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt
index 8b888a54f4..7a4ceefba5 100644
--- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt
@@ -1,6 +1,8 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
- suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either =
- Either.catch {
+ suspend operator fun invoke(
+ userWalletId: UserWalletId,
+ network: Network,
+ xpub: String,
+ ): Either {
+ return try {
+ if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) {
+ return EnableDynamicAddressesError.ConflictingCustomTokens.left()
+ }
dynamicAddressesRepository.enable(userWalletId, network, xpub)
+ Unit.right()
+ } catch (e: Throwable) {
+ EnableDynamicAddressesError.ServiceError(e).left()
}
+ }
}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt
new file mode 100644
index 0000000000..3a62af2f51
--- /dev/null
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt
@@ -0,0 +1,37 @@
+package com.tangem.domain.dynamicaddresses
+
+import com.tangem.blockchainsdk.utils.toBlockchain
+import com.tangem.common.extensions.ByteArrayKey
+import com.tangem.crypto.hdWallet.DerivationPath
+import com.tangem.domain.models.network.Network
+import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.walletmanager.WalletManagersFacade
+import com.tangem.domain.wallets.derivations.DerivationsRepository
+
+/**
+ * Checks if the account-level XPUB key is already derived (no card scan needed).
+ */
+class IsXpubDerivedUseCase(
+ private val walletManagersFacade: WalletManagersFacade,
+ private val derivationsRepository: DerivationsRepository,
+) {
+
+ suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
+ val blockchain = network.toBlockchain()
+ if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
+ if (!blockchain.isBip44DerivationStyleXPUB()) return false
+
+ val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
+ val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false
+ if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false
+ val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT))
+
+ val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey)
+ val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey)
+ return existingKeys[accountPath] != null
+ }
+
+ private companion object {
+ const val ACCOUNT_PATH_DROP_COUNT = 2
+ }
+}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt
new file mode 100644
index 0000000000..ab998d8301
--- /dev/null
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt
@@ -0,0 +1,22 @@
+package com.tangem.domain.dynamicaddresses
+
+import com.tangem.blockchainsdk.utils.toBlockchain
+import com.tangem.domain.models.network.Network
+import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.walletmanager.WalletManagersFacade
+
+/**
+ * Checks if XPUB generation is supported for the given wallet and network (hardware capability check).
+ */
+class IsXpubSupportedUseCase(
+ private val walletManagersFacade: WalletManagersFacade,
+) {
+
+ suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
+ val blockchain = network.toBlockchain()
+ if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
+
+ val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
+ return walletManager.wallet.publicKey.derivationType?.hdKey != null
+ }
+}
\ No newline at end of file
diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt
index c30355fa48..4bcb627e16 100644
--- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt
+++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt
@@ -19,4 +19,7 @@ interface DynamicAddressesRepository {
suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String?
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
+
+ /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */
+ suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
}
\ No newline at end of file
diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt
index 9eefdc6899..a3ee510fde 100644
--- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt
+++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt
@@ -29,6 +29,9 @@ interface DerivationsRepository {
derivations: Map>,
): Map
+ /** Returns already derived extended public keys for the given [seedKey] */
+ suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap
+
/** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt
index e548eef8b7..100979e021 100644
--- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt
+++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt
@@ -1,7 +1,6 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
-import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
@@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase(
error("No derivation found")
}
+ val seedKey = walletManager.wallet.publicKey.seedKey
+ val existingKeys = derivationsRepository.getExistingDerivedKeys(
+ userWalletId = userWalletId,
+ seedKey = ByteArrayKey(seedKey),
+ )
+
var childKey = makeChildKey(
isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(),
extendedPublicKey = hdKey.extendedPublicKey,
derivationPath = hdKey.path,
)
+ // Fill from already derived keys if available
+ if (childKey.extendedPublicKey == null) {
+ existingKeys[childKey.derivationPath]?.let {
+ childKey = childKey.copy(extendedPublicKey = it)
+ }
+ }
+
+ val parentPath = childKey.derivationPath.dropLastNodes(1)
var parentKey = Key(
- derivationPath = childKey.derivationPath.dropLastNodes(1),
- extendedPublicKey = null,
+ derivationPath = parentPath,
+ extendedPublicKey = existingKeys[parentPath],
)
val pendingDerivations = getPendingDerivations(childKey, parentKey)
- val derivedKeys = deriveKeys(
- userWalletId = userWalletId,
- seedKey = walletManager.wallet.publicKey.seedKey,
- paths = pendingDerivations,
- )
+ val derivedKeys = if (pendingDerivations.isNotEmpty()) {
+ deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations)
+ } else {
+ ExtendedPublicKeysMap(emptyMap())
+ }
if (childKey.extendedPublicKey == null) {
childKey = childKey.copy(
@@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase(
}
}
- /**
- * @return true if xpub generation is supported, false otherwise
- */
- suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either = Either.catch {
- val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
- ?: error("Wallet not found for user wallet $userWalletId and network ${network.id}")
-
- val blockchain = network.toBlockchain()
- val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain)
- val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey
-
- val isSupported = isSecp256k1Blockchain && isHdKey != null
-
- return isSupported.right()
- }
-
private suspend fun deriveKeys(
userWalletId: UserWalletId,
seedKey: ByteArray,
diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts
index 51a9203ba8..36bed031e2 100644
--- a/features/tokendetails/impl/build.gradle.kts
+++ b/features/tokendetails/impl/build.gradle.kts
@@ -68,6 +68,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
+ implementation(projects.domain.dynamicAddresses)
+ implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt
index a8f037db6d..035e4511bf 100644
--- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt
@@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
+import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@@ -148,6 +149,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
cloreMigrationModel = model.cloreMigrationModel,
onDismiss = model.bottomSheetNavigation::dismiss,
)
+ is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent(
+ dynamicAddressesDelegate = model.dynamicAddressesDelegate,
+ onDismiss = model.bottomSheetNavigation::dismiss,
+ )
}
@AssistedFactory
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt
new file mode 100644
index 0000000000..e9fe02cce1
--- /dev/null
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt
@@ -0,0 +1,125 @@
+package com.tangem.feature.tokendetails.presentation.tokendetails.model
+
+import com.tangem.common.core.TangemSdkError
+import com.tangem.core.decompose.ui.UiMessageSender
+import com.tangem.core.res.R
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.core.ui.message.SnackbarMessage
+import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
+import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
+import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
+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.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
+import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
+import com.tangem.utils.coroutines.CoroutineDispatcherProvider
+import com.tangem.utils.logging.TangemLogger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+@Suppress("LongParameterList")
+internal class DynamicAddressesDelegate(
+ private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
+ private val isXpubDerivedUseCase: IsXpubDerivedUseCase,
+ private val dynamicAddressesRepository: DynamicAddressesRepository,
+ private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
+ private val uiMessageSender: UiMessageSender,
+ private val userWalletId: UserWalletId,
+ private val network: Network,
+ private val coroutineScope: CoroutineScope,
+ private val dispatchers: CoroutineDispatcherProvider,
+ private val showBottomSheet: () -> Unit,
+ private val dismissBottomSheet: () -> Unit,
+ private val onDynamicAddressesEnabled: () -> Unit,
+) {
+
+ private val _bottomSheetConfig = MutableStateFlow(
+ DynamicAddressesBottomSheetConfig.Enable(
+ isCardScanRequired = false,
+ onEnableClick = {},
+ ),
+ )
+ val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow()
+
+ fun onDynamicAddressesClick() {
+ coroutineScope.launch(dispatchers.main) {
+ val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
+ if (hasConflicts) {
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable(
+ onGotItClick = dismissBottomSheet,
+ )
+ showBottomSheet()
+ return@launch
+ }
+
+ val isCardScanRequired = !isXpubAlreadyDerived()
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
+ isCardScanRequired = isCardScanRequired,
+ onEnableClick = ::onEnableClick,
+ )
+ showBottomSheet()
+ }
+ }
+
+ private fun onEnableClick() {
+ coroutineScope.launch(dispatchers.main) {
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
+ isCardScanRequired = false,
+ isLoading = true,
+ onEnableClick = {},
+ )
+
+ val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold(
+ ifLeft = { error ->
+ if (isUserCancellation(error)) {
+ dismissBottomSheet()
+ } else {
+ TangemLogger.e("Failed to get XPUB: ${error.message}")
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
+ onGotItClick = dismissBottomSheet,
+ )
+ }
+ return@launch
+ },
+ ifRight = { it },
+ )
+
+ enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
+ ifLeft = { error ->
+ when (error) {
+ is EnableDynamicAddressesError.ConflictingCustomTokens -> {
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable(
+ onGotItClick = dismissBottomSheet,
+ )
+ }
+ is EnableDynamicAddressesError.ServiceError -> {
+ TangemLogger.e("Failed to enable DA: ${error.cause.message}")
+ _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
+ onGotItClick = dismissBottomSheet,
+ )
+ }
+ }
+ },
+ ifRight = {
+ dismissBottomSheet()
+ onDynamicAddressesEnabled()
+ uiMessageSender.send(
+ SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)),
+ )
+ },
+ )
+ }
+ }
+
+ private suspend fun isXpubAlreadyDerived(): Boolean {
+ return isXpubDerivedUseCase(userWalletId, network)
+ }
+
+ private fun isUserCancellation(error: Throwable): Boolean {
+ return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled
+ }
+}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt
index af6e80b86e..81e1b3a3d0 100644
--- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt
@@ -48,6 +48,8 @@ interface TokenDetailsClickIntents {
fun onGenerateExtendedKey()
+ fun onDynamicAddressesClick()
+
fun onCopyAddress(): TextReference?
fun onAssociateClick()
@@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onGenerateExtendedKey() { /* no op */ }
+ override fun onDynamicAddressesClick() { /* no op */ }
+
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt
index 4cd0017182..46d9a4eb87 100644
--- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt
@@ -2,13 +2,19 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
-import arrow.core.merge
import arrow.core.right
import com.tangem.utils.logging.TangemLogger
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
+import com.tangem.crypto.hdWallet.DerivationPath
+import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
+import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
+import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
+import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
+import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
+import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
@@ -16,9 +22,7 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
-import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
-import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
@@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
+import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@@ -154,7 +159,6 @@ internal class TokenDetailsModel @Inject constructor(
private val appRouter: AppRouter,
private val router: InnerTokenDetailsRouter,
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
- private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
@@ -163,6 +167,11 @@ internal class TokenDetailsModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase,
private val signCloreMessageUseCase: SignCloreMessageUseCase,
+ private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
+ private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
+ private val isXpubDerivedUseCase: IsXpubDerivedUseCase,
+ private val dynamicAddressesRepository: DynamicAddressesRepository,
+ private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) : Model(),
TokenDetailsClickIntents,
ExpressTransactionsClickIntents,
@@ -226,6 +235,27 @@ internal class TokenDetailsModel @Inject constructor(
}
// endregion
+ // region Dynamic Addresses
+ val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) {
+ DynamicAddressesDelegate(
+ enableDynamicAddressesUseCase = enableDynamicAddressesUseCase,
+ isXpubDerivedUseCase = isXpubDerivedUseCase,
+ dynamicAddressesRepository = dynamicAddressesRepository,
+ getExtendedPublicKeyUseCase = getExtendedPublicKeyForCurrencyUseCase,
+ uiMessageSender = uiMessageSender,
+ userWalletId = userWalletId,
+ network = cryptoCurrency.network,
+ coroutineScope = modelScope,
+ dispatchers = dispatchers,
+ showBottomSheet = {
+ bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses)
+ },
+ dismissBottomSheet = bottomSheetNavigation::dismiss,
+ onDynamicAddressesEnabled = ::onDynamicAddressesEnabled,
+ )
+ }
+ // endregion Dynamic Addresses
+
private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
tokenDetailsExpressStatusFactory.create(
clickIntents = this,
@@ -485,39 +515,45 @@ internal class TokenDetailsModel @Inject constructor(
).getOrElse { false }
val isSupported = isXPUBSupported()
+ val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable()
internalUiState.value = stateFactory.getStateWithUpdatedMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
+ isDynamicAddressesAvailable = isDynamicAddressesAvailable,
)
}
}
+ private fun isDynamicAddressesAvailable(): Boolean {
+ if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false
+ if (cryptoCurrency !is CryptoCurrency.Coin) return false
+
+ val networkId = cryptoCurrency.network.rawId
+ if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false
+
+ return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId)
+ }
+
+ private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean {
+ val pathValue = derivationPath.value ?: return false
+ val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false
+ if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false
+
+ val purposeNode = nodes.first()
+ val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
+ if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
+
+ val changeNode = nodes[nodes.size - 2]
+ val indexNode = nodes.last()
+
+ return changeNode.getIndex(includeHardened = false) == 0L &&
+ indexNode.getIndex(includeHardened = false) == 0L
+ }
+
private suspend fun isXPUBSupported(): Boolean {
- return getExtendedPublicKeyForCurrencyUseCase.isSupported(
- userWalletId = userWalletId,
- network = cryptoCurrency.network,
- )
- .mapLeft { throwable ->
- analyticsExceptionHandler.sendException(
- event = ExceptionAnalyticsEvent(
- exception = throwable,
- params = mapOf(
- "blockchainId" to cryptoCurrency.network.id.rawId.value,
- "networkId" to cryptoCurrency.network.backendId,
- ),
- ),
- )
-
- TangemLogger.e(
- "Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}",
- throwable,
- )
-
- false
- }
- .merge()
+ return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
}
private fun createSelectedAppCurrencyFlow(): StateFlow {
@@ -657,6 +693,15 @@ internal class TokenDetailsModel @Inject constructor(
openStaking()
}
+ override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
+
+ private fun onDynamicAddressesEnabled() {
+ updateTopBarMenu()
+ modelScope.launch(dispatchers.main) {
+ cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency)
+ }
+ }
+
override fun onGenerateExtendedKey() {
modelScope.launch(dispatchers.main) {
val extendedKey = getExtendedPublicKeyForCurrencyUseCase(
@@ -1382,5 +1427,6 @@ internal class TokenDetailsModel @Inject constructor(
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
+ const val BASE_DERIVATION_NODE_COUNT = 5
}
}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt
index 8ba904843b..071fa53e51 100644
--- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt
@@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable
data object CloreMigration : TokenDetailsBottomSheetConfig()
+
+ @Serializable
+ data object DynamicAddresses : TokenDetailsBottomSheetConfig()
}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt
index 3739c109ba..bd9da5b438 100644
--- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt
@@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
+ isDynamicAddressesAvailable: Boolean = false,
): TokenDetailsState {
return with(currentStateProvider()) {
copy(
topAppBarConfig = topAppBarConfig.copy(
tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig
- ?.updateMenu(userWallet, hasDerivations, isSupported),
+ ?.updateMenu(
+ userWallet = userWallet,
+ hasDerivations = hasDerivations,
+ isSupported = isSupported,
+ isDynamicAddressesAvailable = isDynamicAddressesAvailable,
+ ),
),
)
}
@@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
+ isDynamicAddressesAvailable: Boolean,
): TokenDetailsAppBarMenuConfig? {
if (userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
@@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory(
return copy(
items = buildList {
+ if (isDynamicAddressesAvailable) {
+ TangemDropdownMenuItem(
+ title = resourceReference(R.string.dynamic_addresses),
+ textColor = themedColor { TangemTheme.colors.text.primary1 },
+ onClick = tokenDetailsClickIntents::onDynamicAddressesClick,
+ ).let(::add)
+ }
if (isSupported && hasDerivations) {
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt
new file mode 100644
index 0000000000..6b476ee779
--- /dev/null
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt
@@ -0,0 +1,34 @@
+package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
+import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
+import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate
+import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet
+
+internal class DynamicAddressesBottomSheetComponent(
+ private val dynamicAddressesDelegate: DynamicAddressesDelegate,
+ private val onDismiss: () -> Unit,
+) : ComposableBottomSheetComponent {
+
+ override fun dismiss() {
+ onDismiss()
+ }
+
+ @Composable
+ override fun BottomSheet() {
+ val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle()
+
+ val config = remember(content) {
+ TangemBottomSheetConfig(
+ isShown = true,
+ onDismissRequest = ::dismiss,
+ content = content,
+ )
+ }
+ DynamicAddressesBottomSheet(config = config)
+ }
+}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt
new file mode 100644
index 0000000000..a12557f768
--- /dev/null
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt
@@ -0,0 +1,28 @@
+package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
+
+import androidx.compose.runtime.Composable
+import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
+import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
+import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
+import com.tangem.core.ui.R as CoreR
+
+@Composable
+internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) {
+ TangemModalBottomSheet(
+ config = config,
+ title = {
+ TangemModalBottomSheetTitle(
+ endIconRes = CoreR.drawable.ic_close_24,
+ onEndClick = config.onDismissRequest,
+ )
+ },
+ ) { content ->
+ when (content) {
+ is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content)
+ is DynamicAddressesBottomSheetConfig.Unavailable -> DynamicAddressesUnavailableContent(content = content)
+ is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> DynamicAddressesServiceUnavailableContent(
+ content = content,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt
new file mode 100644
index 0000000000..a228fb29b2
--- /dev/null
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt
@@ -0,0 +1,22 @@
+package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
+
+import androidx.compose.runtime.Immutable
+import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
+
+@Immutable
+internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent {
+
+ data class Enable(
+ val isCardScanRequired: Boolean,
+ val isLoading: Boolean = false,
+ val onEnableClick: () -> Unit,
+ ) : DynamicAddressesBottomSheetConfig()
+
+ data class Unavailable(
+ val onGotItClick: () -> Unit,
+ ) : DynamicAddressesBottomSheetConfig()
+
+ data class ServiceUnavailable(
+ val onGotItClick: () -> Unit,
+ ) : DynamicAddressesBottomSheetConfig()
+}
\ No newline at end of file
diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt
new file mode 100644
index 0000000000..fb1eaa7605
--- /dev/null
+++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt
@@ -0,0 +1,179 @@
+package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import com.tangem.core.ui.components.PrimaryButton
+import com.tangem.core.ui.extensions.stringResourceSafe
+import com.tangem.core.ui.res.TangemTheme
+import com.tangem.core.res.R
+import com.tangem.core.ui.R as CoreR
+
+@Composable
+internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = TangemTheme.dimens.spacing16),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top),
+ contentDescription = null,
+ modifier = Modifier.size(TangemTheme.dimens.size44),
+ tint = TangemTheme.colors.icon.accent,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
+
+ Text(
+ text = stringResourceSafe(id = R.string.dynamic_addresses),
+ style = TangemTheme.typography.h3,
+ color = TangemTheme.colors.text.primary1,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
+
+ Text(
+ text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle),
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors.text.secondary,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
+
+ FeatureItem(
+ iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24,
+ title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title),
+ description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description),
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
+
+ FeatureItem(
+ iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24,
+ title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title),
+ description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description),
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
+
+ PrimaryButton(
+ text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title),
+ onClick = content.onEnableClick,
+ modifier = Modifier.fillMaxWidth(),
+ showProgress = content.isLoading,
+ enabled = !content.isLoading,
+ // TODO add card icon when isCardScanRequired
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
+ }
+}
+
+@Composable
+internal fun DynamicAddressesUnavailableContent(content: DynamicAddressesBottomSheetConfig.Unavailable) {
+ ErrorContent(
+ titleRes = R.string.dynamic_addresses_error_has_custom_token_title,
+ descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description,
+ buttonTextRes = R.string.common_got_it,
+ onButtonClick = content.onGotItClick,
+ )
+}
+
+@Composable
+internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) {
+ ErrorContent(
+ titleRes = R.string.dynamic_addresses_error_service_unavailable_title,
+ descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description,
+ buttonTextRes = R.string.common_got_it,
+ onButtonClick = content.onGotItClick,
+ )
+}
+
+@Composable
+private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = TangemTheme.dimens.spacing16),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
+ contentDescription = null,
+ modifier = Modifier.size(TangemTheme.dimens.size44),
+ tint = TangemTheme.colors.icon.warning,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
+
+ Text(
+ text = stringResourceSafe(id = titleRes),
+ style = TangemTheme.typography.h3,
+ color = TangemTheme.colors.text.primary1,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
+
+ Text(
+ text = stringResourceSafe(id = descriptionRes),
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors.text.secondary,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
+
+ PrimaryButton(
+ text = stringResourceSafe(id = buttonTextRes),
+ onClick = onButtonClick,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
+ }
+}
+
+@Composable
+private fun FeatureItem(iconRes: Int, title: String, description: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(TangemTheme.dimens.size24),
+ tint = TangemTheme.colors.icon.accent,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = TangemTheme.typography.subtitle1,
+ color = TangemTheme.colors.text.primary1,
+ )
+ Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4))
+ Text(
+ text = description,
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors.text.secondary,
+ )
+ }
+ }
+}
\ No newline at end of file