Updated on 2026-08-14
This commit is contained in:
parent
52a607c1f5
commit
9944036c4d
29 changed files with 874 additions and 60 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ class FinalizeTwinTask(
|
|||
visaCardScanHandler = null,
|
||||
visaCoroutineScope = null,
|
||||
shouldCheckIsAlreadyActivated = false,
|
||||
isDynamicAddressesEnabled = false,
|
||||
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
|
||||
onboardingV2FeatureToggles = null,
|
||||
).run(session, callback)
|
||||
is CompletionResult.Failure ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M11.999,1.25C13.68,1.25 15.047,1.818 16.24,2.436C16.6,2.622 16.935,2.807 17.255,2.983C18.038,3.415 18.733,3.797 19.495,4.04C19.942,4.183 20.338,4.31 20.619,4.42C20.883,4.523 21.198,4.665 21.42,4.913C21.621,5.136 21.723,5.385 21.792,5.624C21.854,5.841 21.907,6.135 21.958,6.417C23.196,13.282 20.492,19.787 13.881,22.318C13.232,22.567 12.753,22.75 12.002,22.75C11.251,22.75 10.772,22.567 10.123,22.318C3.512,19.788 0.806,13.283 2.043,6.417C2.094,6.135 2.147,5.841 2.209,5.624C2.277,5.385 2.38,5.135 2.58,4.913C2.802,4.665 3.117,4.524 3.381,4.42C3.662,4.31 4.059,4.183 4.506,4.04C5.268,3.797 5.961,3.415 6.744,2.983C7.063,2.807 7.398,2.622 7.758,2.436C8.95,1.818 10.317,1.25 11.999,1.25ZM17.942,8.666C17.757,8.146 17.186,7.873 16.666,8.058C15.788,8.369 14.948,8.961 14.212,9.607C13.465,10.262 12.766,11.023 12.172,11.734C11.733,12.261 11.345,12.769 11.03,13.199C10.741,12.854 10.453,12.601 10.179,12.418C9.905,12.235 9.552,12 9,12C8.447,12 8,12.448 8,13C8,13.521 8.397,13.949 8.906,13.996C9.11,14.015 9.664,14.566 10.105,15.447C10.266,15.77 10.589,15.981 10.949,15.999C11.309,16.017 11.65,15.84 11.843,15.536C11.849,15.528 12.193,15.012 12.357,14.78C12.688,14.311 13.157,13.677 13.708,13.016C14.262,12.352 14.887,11.675 15.53,11.111C16.183,10.539 16.8,10.132 17.333,9.943C17.854,9.759 18.126,9.187 17.942,8.666Z"
|
||||
android:fillColor="#0099FF"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="72dp"
|
||||
android:height="72dp"
|
||||
android:viewportWidth="72"
|
||||
android:viewportHeight="72">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M36,0L36,0A36,36 0,0 1,72 36L72,36A36,36 0,0 1,36 72L36,72A36,36 0,0 1,0 36L0,36A36,36 0,0 1,36 0z"/>
|
||||
<path
|
||||
android:pathData="M0,0h72v72h-72z"
|
||||
android:strokeAlpha="0.1"
|
||||
android:fillColor="#0099FF"
|
||||
android:fillAlpha="0.1"/>
|
||||
<path
|
||||
android:pathData="M24.607,31.142C23.778,32.206 22.682,31.815 22.291,30.454L20.319,23.678C20.006,22.598 20.663,21.753 21.79,21.8L28.848,22.035C30.256,22.081 30.882,23.067 30.069,24.116L28.66,25.915L28.723,25.947C31.602,27.856 35.123,31.831 35.968,34.46H36.031C36.86,31.846 40.397,27.856 43.276,25.947L43.354,25.9L41.915,23.912C41.132,22.833 41.774,21.862 43.198,21.878L50.256,21.847C51.382,21.847 52.008,22.723 51.664,23.803L49.473,30.501C49.051,31.846 47.924,32.206 47.126,31.095L45.608,28.998L45.264,29.217C42.118,31.22 37.893,36.15 37.893,39.734V48.106C37.893,49.467 37.22,50.203 36,50.203C34.779,50.203 34.122,49.467 34.122,48.106V39.734C34.122,36.15 29.865,31.22 26.735,29.217L26.313,28.951L24.607,31.142Z"
|
||||
android:fillColor="#0099FF"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="56dp"
|
||||
android:height="56dp"
|
||||
android:viewportWidth="56"
|
||||
android:viewportHeight="56">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M28,0L28,0A28,28 0,0 1,56 28L56,28A28,28 0,0 1,28 56L28,56A28,28 0,0 1,0 28L0,28A28,28 0,0 1,28 0z"/>
|
||||
<path
|
||||
android:pathData="M0,0h56v56h-56z"
|
||||
android:strokeAlpha="0.1"
|
||||
android:fillColor="#FFB71B"
|
||||
android:fillAlpha="0.1"/>
|
||||
<path
|
||||
android:pathData="M41.646,35.377C41.646,37.37 40.305,38.906 38.091,38.906H17.909C15.696,38.906 14.354,37.37 14.354,35.377C14.354,34.765 14.511,34.14 14.862,33.555L24.966,15.95C25.631,14.766 26.802,14.167 28,14.167C29.198,14.167 30.357,14.766 31.034,15.95L41.138,33.555C41.464,34.127 41.646,34.765 41.646,35.377ZM28.013,30.208C27.336,30.208 26.959,29.817 26.946,29.127L26.776,22.044C26.763,21.354 27.271,20.859 28,20.859C28.703,20.859 29.25,21.367 29.237,22.057L29.042,29.127C29.029,29.831 28.651,30.208 28.013,30.208ZM28.013,34.57C27.232,34.57 26.555,33.945 26.555,33.177C26.555,32.396 27.219,31.771 28.013,31.771C28.795,31.771 29.459,32.383 29.459,33.177C29.459,33.958 28.782,34.57 28.013,34.57Z"
|
||||
android:fillColor="#FFB71B"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M13.608,1.147C14.314,1.448 14.797,2.163 14.797,3.017L14.798,9.785C14.798,9.895 14.887,9.985 14.998,9.985H18.099C18.985,9.985 19.595,10.583 19.847,11.21C20.097,11.837 20.064,12.642 19.563,13.285L12.564,22.268C12.003,22.988 11.12,23.163 10.392,22.852C9.685,22.551 9.202,21.837 9.202,20.983L9.202,14.215C9.202,14.104 9.112,14.015 9.002,14.015H5.9C5.014,14.015 4.404,13.417 4.153,12.789C3.902,12.163 3.936,11.358 4.437,10.715L11.435,1.732C11.996,1.011 12.879,0.837 13.608,1.147Z"
|
||||
android:fillColor="#0099FF"/>
|
||||
</vector>
|
||||
|
|
@ -30,6 +30,7 @@ dependencies {
|
|||
|
||||
// region Project - Libs
|
||||
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ByteArrayKey, ExtendedPublicKeysMap> {
|
||||
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<BackendId, String?>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<String, Long> = 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]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
sealed class EnableDynamicAddressesError {
|
||||
|
||||
data object ConflictingCustomTokens : EnableDynamicAddressesError()
|
||||
|
||||
data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError()
|
||||
}
|
||||
|
|
@ -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<Throwable, Unit> =
|
||||
Either.catch {
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
xpub: String,
|
||||
): Either<EnableDynamicAddressesError, Unit> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -29,6 +29,9 @@ interface DerivationsRepository {
|
|||
derivations: Map<ByteArrayKey, List<DerivationPath>>,
|
||||
): Map<ByteArrayKey, ExtendedPublicKeysMap>
|
||||
|
||||
/** 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,
|
||||
|
|
|
|||
|
|
@ -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<Throwable, Boolean> = 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
DynamicAddressesBottomSheetConfig.Enable(
|
||||
isCardScanRequired = false,
|
||||
onEnableClick = {},
|
||||
),
|
||||
)
|
||||
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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<AppCurrency> {
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
|
|||
|
||||
@Serializable
|
||||
data object CloreMigration : TokenDetailsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DynamicAddressesBottomSheetConfig>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue