Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-13 19:48:06 +03:00
parent 27e9400b2a
commit b38ebb3995
16 changed files with 196 additions and 71 deletions

View file

@ -5,7 +5,7 @@ import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
@ -69,10 +69,10 @@ internal object DynamicAddressesDomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideIsXpubDerivedUseCase( fun provideGetDerivedXpubUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
derivationsRepository: DerivationsRepository, derivationsRepository: DerivationsRepository,
): IsXpubDerivedUseCase { ): GetDerivedXpubUseCase {
return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository) return GetDerivedXpubUseCase(walletManagersFacade, derivationsRepository)
} }
} }

View file

@ -42,7 +42,8 @@ data class UserTokensResponse(
return otherToken.contractAddress == this.contractAddress && return otherToken.contractAddress == this.contractAddress &&
otherToken.networkId == this.networkId && otherToken.networkId == this.networkId &&
otherToken.derivationPath == this.derivationPath && otherToken.derivationPath == this.derivationPath &&
otherToken.decimals == this.decimals otherToken.decimals == this.decimals &&
otherToken.dynamicAddressesEnabled == this.dynamicAddressesEnabled
} }
override fun hashCode(): Int = calculateHashCode( override fun hashCode(): Int = calculateHashCode(
@ -50,6 +51,7 @@ data class UserTokensResponse(
networkId.hashCode(), networkId.hashCode(),
derivationPath.hashCode(), derivationPath.hashCode(),
decimals.hashCode(), decimals.hashCode(),
dynamicAddressesEnabled.hashCode(),
) )
} }

View file

@ -49,7 +49,12 @@ internal class DefaultDynamicAddressesRepository(
} }
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } .onFailure { throwable ->
TangemLogger.e(
messageString = "Failed to sync tokens after dynamic addresses enable for $userWalletId",
throwable = throwable,
)
}
} }
} }
@ -61,7 +66,12 @@ internal class DefaultDynamicAddressesRepository(
} }
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } .onFailure { throwable ->
TangemLogger.e(
messageString = "Failed to sync tokens after dynamic addresses disable for $userWalletId",
throwable = throwable,
)
}
} }
} }
@ -140,7 +150,7 @@ internal class DefaultDynamicAddressesRepository(
} }
private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean {
// Check if WalletManager is already in XPUB mode (DA was previously enabled on this device) // Check if WalletManager is already in XPUB mode (dynamic addresses was previously enabled on this device)
return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null
} }

View file

@ -0,0 +1,42 @@
package com.tangem.data.dynamicaddresses
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Provides XPUB strings for networks that need dynamic addresses restore (ENABLED_REQUIRES_SETUP).
* Uses only already-derived keys no card scan triggered.
*/
@Singleton
class DynamicAddressesInitializer @Inject constructor(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
) {
suspend fun getXpubs(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, String> {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap()
val result = mutableMapOf<Network, String>()
for (network in networks) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).firstOrNull()
if (status != DynamicAddressesStatus.ENABLED_REQUIRES_SETUP) continue
val xpub = getDerivedXpubUseCase(userWalletId, network)
if (xpub != null) {
result[network] = xpub
} else {
TangemLogger.w("Dynamic addresses enabled but XPUB not available for ${network.id}")
}
}
return result
}
}

View file

@ -21,6 +21,7 @@ dependencies {
// region Project - Data // region Project - Data
implementation(projects.data.common) implementation(projects.data.common)
implementation(projects.data.dynamicAddresses)
// endregion // endregion
// region Project - Domain // region Project - Domain

View file

@ -43,6 +43,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
networkCurrencies: Set<CryptoCurrency>, networkCurrencies: Set<CryptoCurrency>,
xpub: String? = null,
): Either<Throwable, Unit> { ): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.default) { return Either.catchOn(dispatchers.default) {
val result = withContext(dispatchers.io) { val result = withContext(dispatchers.io) {
@ -52,6 +53,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
extraTokens = networkCurrencies extraTokens = networkCurrencies
.filterIsInstance<CryptoCurrency.Token>() .filterIsInstance<CryptoCurrency.Token>()
.toSet(), .toSet(),
xpub = xpub,
) )
} }

View file

@ -2,16 +2,18 @@ package com.tangem.data.networks.multi
import arrow.core.raise.catch import arrow.core.raise.catch
import arrow.core.raise.ensure import arrow.core.raise.ensure
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsCache import com.tangem.data.networks.store.setSourceAsCache
import com.tangem.data.networks.store.setSourceAsOnlyCache import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -27,11 +29,11 @@ import javax.inject.Inject
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
@Suppress("LongParameterList")
internal class DefaultMultiNetworkStatusFetcher @Inject constructor( internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
private val networksStatusesStore: NetworksStatusesStore, private val networksStatusesStore: NetworksStatusesStore,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher, private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher,
private val dynamicAddressesInitializer: DynamicAddressesInitializer,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MultiNetworkStatusFetcher { ) : MultiNetworkStatusFetcher {
@ -50,6 +52,14 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
}, },
) )
val xpubByNetwork = catch(
block = { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) },
catch = { error ->
TangemLogger.e("Failed to build XPUBs for restore", error)
emptyMap()
},
)
val result = coroutineScope { val result = coroutineScope {
params.networks params.networks
.map { network -> .map { network ->
@ -58,6 +68,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = network, network = network,
networkCurrencies = networksCurrencies[network].orEmpty().toSet(), networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
xpub = xpubByNetwork[network],
) )
} }
} }

View file

@ -3,6 +3,7 @@ package com.tangem.data.networks.multi
import arrow.core.Either import arrow.core.Either
import arrow.core.left import arrow.core.left
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore
@ -27,17 +28,21 @@ internal class DefaultMultiNetworkStatusFetcherTest {
private val networksStatusesStore: NetworksStatusesStore = mockk(relaxUnitFun = true) private val networksStatusesStore: NetworksStatusesStore = mockk(relaxUnitFun = true)
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher = mockk() private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher = mockk()
private val dynamicAddressesInitializer: DynamicAddressesInitializer = mockk()
private val fetcher = DefaultMultiNetworkStatusFetcher( private val fetcher = DefaultMultiNetworkStatusFetcher(
networksStatusesStore = networksStatusesStore, networksStatusesStore = networksStatusesStore,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
commonNetworkStatusFetcher = commonNetworkStatusFetcher, commonNetworkStatusFetcher = commonNetworkStatusFetcher,
dynamicAddressesInitializer = dynamicAddressesInitializer,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
) )
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher) clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher, dynamicAddressesInitializer)
// No dynamic addresses restore by default
coEvery { dynamicAddressesInitializer.getXpubs(any(), any()) } returns emptyMap()
} }
@Test @Test
@ -62,6 +67,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
} returns ethereumFetcherResult } returns ethereumFetcherResult
@ -70,6 +76,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} returns cardanoFetcherResult } returns cardanoFetcherResult
@ -87,11 +94,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
commonNetworkStatusFetcher.fetch( commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} }
@ -122,6 +131,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
} returns ethereumFetcherResult } returns ethereumFetcherResult
@ -130,6 +140,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} returns cardanoFetcherResult } returns cardanoFetcherResult
@ -147,11 +158,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
commonNetworkStatusFetcher.fetch( commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} }
@ -182,6 +195,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
} returns ethereumFetcherResult } returns ethereumFetcherResult
@ -190,6 +204,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} returns cardanoFetcherResult } returns cardanoFetcherResult
@ -207,11 +222,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = ethereum.network, network = ethereum.network,
networkCurrencies = setOf(ethereum), networkCurrencies = setOf(ethereum),
xpub = null,
) )
commonNetworkStatusFetcher.fetch( commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = cardano.network, network = cardano.network,
networkCurrencies = setOf(cardano), networkCurrencies = setOf(cardano),
xpub = null,
) )
} }
@ -245,7 +262,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
networksStatusesStore.setSourceAsOnlyCache(params.userWalletId, params.networks) networksStatusesStore.setSourceAsOnlyCache(params.userWalletId, params.networks)
} }
coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any()) } coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any(), any()) }
} }
private companion object { private companion object {

View file

@ -59,7 +59,7 @@ import java.util.EnumSet
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
@Suppress("LargeClass", "TooManyFunctions") @Suppress("LargeClass", "TooManyFunctions", "LongParameterList")
internal class DefaultWalletManagersFacade @Inject constructor( internal class DefaultWalletManagersFacade @Inject constructor(
private val walletManagersStore: WalletManagersStore, private val walletManagersStore: WalletManagersStore,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
@ -85,6 +85,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
extraTokens: Set<CryptoCurrency.Token>, extraTokens: Set<CryptoCurrency.Token>,
xpub: String?,
): UpdateWalletManagerResult { ): UpdateWalletManagerResult {
val userWallet = getUserWallet(userWalletId) val userWallet = getUserWallet(userWalletId)
val blockchain = network.toBlockchain() val blockchain = network.toBlockchain()
@ -95,6 +96,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
blockchain = blockchain, blockchain = blockchain,
derivationPath = derivationPath, derivationPath = derivationPath,
extraTokens = extraTokens, extraTokens = extraTokens,
xpub = xpub,
) )
} }
@ -309,6 +311,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
blockchain: Blockchain, blockchain: Blockchain,
derivationPath: String?, derivationPath: String?,
extraTokens: Set<CryptoCurrency.Token>, extraTokens: Set<CryptoCurrency.Token>,
xpub: String? = null,
): UpdateWalletManagerResult { ): UpdateWalletManagerResult {
if (derivationPath != null && !userWallet.hasDerivation(blockchain, derivationPath)) { if (derivationPath != null && !userWallet.hasDerivation(blockchain, derivationPath)) {
TangemLogger.w("Derivation missed for: $blockchain") TangemLogger.w("Derivation missed for: $blockchain")
@ -326,6 +329,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
} }
val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens) val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens)
if (xpub != null) restoreXpubModeIfNeeded(walletManager, xpub)
return try { return try {
if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) {
@ -499,6 +503,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
} }
} }
private fun restoreXpubModeIfNeeded(walletManager: WalletManager, xpub: String) {
val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return
try {
dynamicAddressesManager.enableDynamicAddresses(xpub)
TangemLogger.i("Restored XPUB mode for ${walletManager.wallet.blockchain}")
} catch (e: Exception) {
TangemLogger.e("Failed to restore XPUB mode: ${e.message}")
}
}
// endregion Dynamic Addresses // endregion Dynamic Addresses
@Deprecated("Will be removed in future") @Deprecated("Will be removed in future")

View file

@ -11,7 +11,7 @@ class DisableDynamicAddressesUseCase(
/** /**
* Returns true when consolidation is required before disabling (non-base balances exist), * Returns true when consolidation is required before disabling (non-base balances exist),
* or false when DA was disabled immediately. * or false when dynamic addresses was disabled immediately.
*/ */
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> = suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
Either.catch { Either.catch {

View file

@ -1,12 +1,13 @@
package com.tangem.domain.dynamicaddresses package com.tangem.domain.dynamicaddresses
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
/** /**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). * 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. * Dynamic addresses is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported. * Only the default derivation style per blockchain is supported.
*/ */
object DynamicAddressesSupportedBlockchains { object DynamicAddressesSupportedBlockchains {
@ -26,22 +27,22 @@ object DynamicAddressesSupportedBlockchains {
Blockchain.RavencoinTestnet, Blockchain.RavencoinTestnet,
) )
private val supportedNetworkIds = supported.map { it.id }.toSet() private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet()
/** /**
* Allowed BIP purpose nodes per network ID. * Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH). * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/ */
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap { private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.id, BIP84_PURPOSE) put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE) put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.Litecoin.id, BIP84_PURPOSE) put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinCash.id, BIP44_PURPOSE) put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE) put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dogecoin.id, BIP44_PURPOSE) put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dash.id, BIP44_PURPOSE) put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Ravencoin.id, BIP44_PURPOSE) put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE) put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE)
} }
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported

View file

@ -0,0 +1,60 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.calculateRipemd160
import com.tangem.common.extensions.calculateSha256
import com.tangem.crypto.NetworkType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
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
/**
* Returns the XPUB string if account-level keys are already derived (no card scan needed),
* or null if keys are not available.
*/
class GetDerivedXpubUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val derivationsRepository: DerivationsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): String? {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return null
if (!blockchain.isBip44DerivationStyleXPUB()) return null
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return null
val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return null
if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return null
val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey)
val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey)
val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT))
val parentPath = DerivationPath(accountPath.nodes.dropLast(1))
val childExtKey = existingKeys[accountPath] ?: return null
val parentExtKey = existingKeys[parentPath] ?: return null
val parentFingerprint = parentExtKey.publicKey
.calculateSha256().calculateRipemd160()
.take(PARENT_FINGERPRINT_SIZE).toByteArray()
val net = if (blockchain.isTestnet()) NetworkType.Testnet else NetworkType.Mainnet
return ExtendedPublicKey(
publicKey = childExtKey.publicKey,
chainCode = childExtKey.chainCode,
depth = accountPath.nodes.size,
parentFingerprint = parentFingerprint,
childNumber = accountPath.nodes.last().index,
).serialize(net)
}
private companion object {
const val ACCOUNT_PATH_DROP_COUNT = 2
const val PARENT_FINGERPRINT_SIZE = 4
}
}

View file

@ -1,37 +0,0 @@
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
}
}

View file

@ -20,6 +20,6 @@ interface DynamicAddressesRepository {
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */ /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
} }

View file

@ -38,12 +38,14 @@ interface WalletManagersFacade {
* @param userWalletId The ID of the user's wallet. * @param userWalletId The ID of the user's wallet.
* @param network The network. * @param network The network.
* @param extraTokens Additional tokens. * @param extraTokens Additional tokens.
* @param xpub XPUB string to restore dynamic addresses mode if not yet active.
* @return The result of updating the wallet manager. * @return The result of updating the wallet manager.
*/ */
suspend fun update( suspend fun update(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
extraTokens: Set<CryptoCurrency.Token>, extraTokens: Set<CryptoCurrency.Token>,
xpub: String? = null,
): UpdateWalletManagerResult ): UpdateWalletManagerResult
/** /**

View file

@ -12,7 +12,7 @@ import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -44,7 +44,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase, private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase, private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase,
private val isXpubDerivedUseCase: IsXpubDerivedUseCase, private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
@ -138,7 +138,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
) )
} }
is EnableDynamicAddressesError.ServiceError -> { is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable DA: ${error.cause.message}") TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet, onDismissClick = dismissBottomSheet,
) )
@ -201,7 +201,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
) )
} }
.onFailure { e -> .onFailure { e ->
TangemLogger.e("Failed to disable DA: ${e.message}") TangemLogger.e("Failed to disable dynamic addresses: ${e.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet, onDismissClick = dismissBottomSheet,
) )
@ -277,9 +277,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
) )
} }
// TODO: Replace with actual DA documentation URL
private fun onReadMoreClick() { private fun onReadMoreClick() {
// Stub: open documentation about Dynamic Addresses consolidation // TODO: Replace with actual URL
} }
private fun onDisableClick() { private fun onDisableClick() {
@ -319,7 +318,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
try { try {
dynamicAddressesRepository.disable(userWalletId, network) dynamicAddressesRepository.disable(userWalletId, network)
} catch (e: Exception) { } catch (e: Exception) {
TangemLogger.e("Failed to disable DA after consolidation: ${e.message}") TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}")
} }
dismissBottomSheet() dismissBottomSheet()
onDynamicAddressesStateChanged() onDynamicAddressesStateChanged()
@ -338,7 +337,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
// region Common // region Common
private suspend fun isXpubAlreadyDerived(network: Network): Boolean { private suspend fun isXpubAlreadyDerived(network: Network): Boolean {
return isXpubDerivedUseCase(userWalletId, network) return getDerivedXpubUseCase(userWalletId, network) != null
} }
private fun isUserCancellation(error: Throwable): Boolean { private fun isUserCancellation(error: Throwable): Boolean {