Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-14 23:42:14 +04:00
parent 040b5f0cf6
commit 505df4db90
13 changed files with 326 additions and 352 deletions

View file

@ -17,7 +17,6 @@ import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
@ -49,7 +48,6 @@ import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@ -121,8 +119,6 @@ interface ApplicationEntryPoint {
fun getOnboardingRepository(): OnboardingRepository
fun getCoroutineDispatcherProvider(): CoroutineDispatcherProvider
fun getExcludedBlockchains(): ExcludedBlockchains
fun getAppLogsStore(): AppLogsStore
@ -155,6 +151,4 @@ interface ApplicationEntryPoint {
fun getABTestsManager(): ABTestsManager
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getWalletAccountsFetcher(): WalletAccountsFetcher
}

View file

@ -31,7 +31,6 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
@ -74,10 +73,8 @@ import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.appReducer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.tasks.product.DerivationsFinder
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.Dispatchers
@ -90,7 +87,6 @@ import timber.log.Timber
lateinit var store: Store<AppState>
val foregroundActivityObserver = ForegroundActivityObserver
internal lateinit var derivationsFinder: DerivationsFinder
open class TangemApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@ -191,9 +187,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val onboardingRepository: OnboardingRepository
get() = entryPoint.getOnboardingRepository()
private val dispatchers: CoroutineDispatcherProvider
get() = entryPoint.getCoroutineDispatcherProvider()
private val excludedBlockchains: ExcludedBlockchains
get() = entryPoint.getExcludedBlockchains()
@ -247,9 +240,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val appsFlyerClientFactory: AppsFlyerClient.Factory
get() = entryPoint.getAppsFlyerClientFactory()
private val walletAccountsFetcher: WalletAccountsFetcher
get() = entryPoint.getWalletAccountsFetcher()
// endregion
private val appScope = MainScope()
@ -352,11 +342,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
)
}
derivationsFinder = DerivationsFinder(
walletAccountsFetcher = walletAccountsFetcher,
dispatchers = dispatchers,
)
appStateHolder.mainStore = store
wcInitializeUseCase.init(

View file

@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -40,6 +41,7 @@ internal class TangemSdkManagerModule {
appFinisher: AppFinisher,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
analyticsExceptionHandler: AnalyticsExceptionHandler,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
@ -56,6 +58,7 @@ internal class TangemSdkManagerModule {
appFinisher = appFinisher,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
blockchainToDeriveFinder = blockchainToDeriveFinder,
dispatchers = dispatchers,
)
}

View file

@ -53,11 +53,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.derivationsFinder
import com.tangem.tap.domain.tasks.product.CreateProductWalletTask
import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
@ -85,6 +81,7 @@ internal class DefaultTangemSdkManager(
private val appFinisher: AppFinisher,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
) : TangemSdkManager {
@ -162,7 +159,7 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain(
runnable = ScanProductTask(
card = null,
derivationsFinder = derivationsFinder,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,

View file

@ -0,0 +1,74 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -1,138 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal data class BlockchainToDerive(
val blockchain: Blockchain,
val derivationPath: DerivationPath?,
)
// FIXME: May be move to DI, currently unnecessary
internal class DerivationsFinder(
private val walletAccountsFetcher: WalletAccountsFetcher,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun findBlockchainsToDerive(
card: CardDTO,
derivationStyleProvider: DerivationStyleProvider,
): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = derivationStyleProvider.getDerivationStyle()
val blockchains = withContext(dispatchers.io) {
getBlockchains(userWalletId)
}.ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
// we should generate second key for cardano
// because cardano address generation for wallet2 requires keys from 2 derivations
// https://developers.cardano.org/docs/get-started/cardano-serialization-lib/generating-keys/
blockchains.addSecondCardanoDerivationIfPresent()
if (card.settings.isHDWalletAllowed) {
blockchains.addEthereumBlockchains(derivationStyle)
}
// pay attention to this
return if (!card.hasOldStyleDerivation) {
blockchains.removeUnnecessaryBlockchains(derivationStyle)
} else {
blockchains
}
}
private suspend fun getBlockchains(userWalletId: UserWalletId): MutableSet<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath)
BlockchainToDerive(blockchain, derivationPath)
}
.toMutableSet()
}
// TODO: Move to user wallet config
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): MutableSet<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
// TODO: Move to user wallet config
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): MutableSet<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
}
private fun MutableSet<BlockchainToDerive>.addEthereumBlockchains(derivationStyle: DerivationStyle?) {
val ethereumBlockchains = setOf(Blockchain.Ethereum)
.mapToBlockchainsWithDerivations(derivationStyle)
addAll(ethereumBlockchains)
}
private fun Set<BlockchainToDerive>.removeUnnecessaryBlockchains(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
val defaultEthereum = BlockchainToDerive(
blockchain = Blockchain.Ethereum,
derivationPath = Blockchain.Ethereum.derivationPath(derivationStyle),
)
val addedEthereum = this.firstOrNull { it == defaultEthereum }
return if (addedEthereum != null) {
filterNot { it.derivationPath == defaultEthereum.derivationPath && it.blockchain != Blockchain.Ethereum }
} else {
// Impossible case because Ethereum was added at the last stage
distinctBy(BlockchainToDerive::derivationPath)
}
.toSet()
}
private fun MutableSet<BlockchainToDerive>.addSecondCardanoDerivationIfPresent() {
val cardanoDerivation = this
.firstOrNull { it.blockchain == Blockchain.Cardano }
?.derivationPath
?: return
val secondCardanoBlockchain = BlockchainToDerive(
blockchain = Blockchain.Cardano,
derivationPath = CardanoUtils.extendedDerivationPath(cardanoDerivation),
)
add(secondCardanoBlockchain)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): MutableSet<BlockchainToDerive> {
return mapTo(hashSetOf()) { blockchain ->
BlockchainToDerive(blockchain, blockchain.derivationPath(derivationStyle))
}
}

View file

@ -13,16 +13,14 @@ import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX
@ -44,11 +42,10 @@ import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.collections.set
internal class ScanProductTask(
private val card: Card?,
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
@ -79,7 +76,7 @@ internal class ScanProductTask(
readVisaCard(
session = session,
cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(derivationsFinder),
scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder),
callback = callback,
)
return
@ -87,7 +84,7 @@ internal class ScanProductTask(
val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(derivationsFinder)
else -> ScanWalletProcessor(blockchainToDeriveFinder)
}
commandProcessor.proceed(cardDto, session) { processorResult ->
when (processorResult) {
@ -160,7 +157,7 @@ internal class ScanProductTask(
}
private class ScanWalletProcessor(
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
) : ProductCommandProcessor<ScanResponse> {
var primaryCard: PrimaryCard? = null
@ -283,7 +280,6 @@ private class ScanWalletProcessor(
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val productType = getWalletProductType(card)
val config = CardConfig.createConfig(card)
scope.launch {
val scanResponse = ScanResponse(
card = card,
@ -291,8 +287,7 @@ private class ScanWalletProcessor(
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
val derivations =
collectDerivations(card, config, scanResponse.derivationStyleProvider)
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse))
return@launch
@ -322,32 +317,13 @@ private class ScanWalletProcessor(
private suspend fun collectDerivations(
card: CardDTO,
config: CardConfig,
derivationStyleProvider: DerivationStyleProvider,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val derivations = mutableMapOf<ByteArrayKey, List<DerivationPath>>()
val blockchains = derivationsFinder
?.findBlockchainsToDerive(card, derivationStyleProvider)
?: return derivations
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
blockchains.forEach { blockchain ->
val curve = config.primaryCurve(blockchain.blockchain)
val wallet = card.wallets.firstOrNull { it.curve == curve } ?: return@forEach
if (wallet.chainCode == null) return@forEach
val key = wallet.publicKey.toMapKey()
val path = blockchain.derivationPath
if (path != null) {
val addedDerivations = derivations[key]
if (addedDerivations != null) {
derivations[key] = addedDerivations + path
} else {
derivations[key] = listOf(path)
}
}
}
return derivations
return MissedDerivationsFinder(scanResponse).findByBlockchainsToDerive(blockchains)
}
}

View file

@ -25,7 +25,7 @@ class FinalizeTwinTask(
is CompletionResult.Success ->
ScanProductTask(
card = readResult.data,
derivationsFinder = null,
blockchainToDeriveFinder = null,
visaCardScanHandler = null,
visaCoroutineScope = null,
onboardingV2FeatureToggles = null,

View file

@ -1,19 +1,17 @@
package com.tangem.tap.domain.tasks.product
import com.google.common.truth.Truth
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@ -24,19 +22,16 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DerivationsFinderTest {
class BlockchainToDeriveFinderTest {
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val finder = DerivationsFinder(
private val finder = BlockchainToDeriveFinder(
walletAccountsFetcher = walletAccountsFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val derivationStyleProvider = mockk<DerivationStyleProvider>()
@AfterEach
fun tearDown() {
clearMocks(walletAccountsFetcher, derivationStyleProvider)
clearMocks(walletAccountsFetcher)
}
@Test
@ -47,7 +42,7 @@ class DerivationsFinderTest {
}
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = mockk())
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
@ -62,29 +57,26 @@ class DerivationsFinderTest {
}
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = mockk())
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN saved bitcoin THEN return bitcoin and ethereum`() = runTest {
fun `GIVEN saved bitcoin THEN return only bitcoin`() = runTest {
// Arrange
val card = createCardDTO()
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
val response = createResponse(Blockchain.Bitcoin)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
@ -98,12 +90,10 @@ class DerivationsFinderTest {
val demoCardId = "AC01000000045754"
val card = createCardDTO(cardId = demoCardId)
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = setOf(
@ -124,12 +114,10 @@ class DerivationsFinderTest {
val demoCardId = "DE00"
val card = createCardDTO(cardId = demoCardId)
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = setOf(
@ -148,12 +136,10 @@ class DerivationsFinderTest {
// Arrange
val card = createCardDTO()
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = setOf(
@ -167,26 +153,19 @@ class DerivationsFinderTest {
}
@Test
fun `GIVEN saved cardano THEN return cardano and ethereum`() = runTest {
fun `GIVEN saved cardano THEN return only cardano`() = runTest {
// Arrange
val card = createCardDTO()
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
val response = createResponse(Blockchain.Cardano)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Cardano),
createExpected(
blockchain = Blockchain.Cardano,
derivationPath = CardanoUtils.extendedDerivationPath(Blockchain.Cardano.getDerivationPath())
),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
@ -195,53 +174,18 @@ class DerivationsFinderTest {
}
@Test
fun `GIVEN saved eth-like blockchains for v3 config wallet THEN return only unique evm derivations`() = runTest {
fun `GIVEN saved eth-like blockchains THEN return all saved blockchains without filtering`() = runTest {
// Arrange
val card = createCardDTO()
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V3
val blockchains = listOf(Blockchain.Ethereum, Blockchain.BSC, Blockchain.Polygon)
val blockchains = Blockchain.entries
.filter { it.isEvm() && !it.isTestnet() }
.toTypedArray()
val response = createResponse(*blockchains)
val response = createResponse(*blockchains.toTypedArray())
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
// Assert
val expected = setOf(
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.EthereumClassic),
createExpected(Blockchain.Quai),
createExpected(Blockchain.XDC),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN card has old style derivation (v1 config) THEN return all eth-like blockchains`() = runTest {
// Arrange
val oldBatchId = "AC01"
val card = createCardDTO(batchId = oldBatchId)
every { derivationStyleProvider.getDerivationStyle() } returns DerivationStyle.V1
val blockchains = Blockchain.entries
.filter { it.isEvm() && !it.isTestnet() }
.toTypedArray()
val response = createResponse(*blockchains)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.findBlockchainsToDerive(card = card, derivationStyleProvider = derivationStyleProvider)
val actual = finder.find(card)
// Assert
val expected = blockchains.mapTo(hashSetOf(), ::createExpected)
@ -260,6 +204,13 @@ class DerivationsFinderTest {
every { this@mockk.cardId } returns cardId
every { this@mockk.batchId } returns batchId
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.settings.isKeysImportAllowed } returns true
every { this@mockk.firmwareVersion } returns CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = com.tangem.common.card.FirmwareVersion.FirmwareType.Release,
)
every { this@mockk.wallets } returns listOf(wallet)
}
}
@ -290,7 +241,7 @@ class DerivationsFinderTest {
}
private fun Blockchain.getDerivationPath(): DerivationPath {
return derivationPath(derivationStyleProvider.getDerivationStyle())!!
return derivationPath(DerivationStyle.V3)!!
}
private companion object {
@ -298,4 +249,4 @@ class DerivationsFinderTest {
// for byteArrayOf(0)
val userWalletId = UserWalletId("41448576B8DA24C7D8F5F0F79863D20D7D8312A7F9E50D3248304136DDB7AAD7")
}
}
}

View file

@ -73,7 +73,7 @@ object MockScanResponseFactory {
CardDTO.Wallet(
CardWallet(
publicKey = curve.name.toByteArray(), // IMPORTANT: public key must equal to curve name
chainCode = null,
chainCode = ByteArray(32), // chainCode must not be null for HD wallets
curve = curve,
settings = createSettings(),
totalSignedHashes = null,

View file

@ -0,0 +1,101 @@
package com.tangem.data.wallets.derivations
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.config.ColdCurvesConfig
import com.tangem.domain.wallets.config.CurvesConfig
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.operations.derivation.ExtendedPublicKeysMap
/**
* Source of derivations data
*/
internal sealed interface DerivationsSource {
val isHDWalletAllowed: Boolean
val hasOldStyleDerivation: Boolean
val curvesConfig: CurvesConfig
val derivationStyleProvider: DerivationStyleProvider
fun getWalletPublicKey(curve: EllipticCurve): ByteArray?
fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap
data class FromUserWallet(val userWallet: UserWallet) : DerivationsSource {
override val isHDWalletAllowed: Boolean
get() = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.settings.isHDWalletAllowed
is UserWallet.Hot -> true
}
override val hasOldStyleDerivation: Boolean
get() = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.hasOldStyleDerivation
is UserWallet.Hot -> false
}
override val curvesConfig: CurvesConfig
get() = userWallet.curvesConfig
override val derivationStyleProvider: DerivationStyleProvider
get() = userWallet.derivationStyleProvider
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.getWalletPublicKey(curve)
is UserWallet.Hot -> userWallet.wallets
?.firstOrNull { it.curve == curve && it.chainCode != null }
?.publicKey
}
}
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.getDerivedKeys(publicKey)
is UserWallet.Hot -> {
val derivedKeys = userWallet.wallets
?.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }
?.derivedKeys
.orEmpty()
ExtendedPublicKeysMap(derivedKeys)
}
}
}
}
data class FromScanResponse(val scanResponse: ScanResponse) : DerivationsSource {
override val isHDWalletAllowed: Boolean
get() = scanResponse.card.settings.isHDWalletAllowed
override val hasOldStyleDerivation: Boolean
get() = scanResponse.card.hasOldStyleDerivation
override val curvesConfig: CurvesConfig
get() = ColdCurvesConfig(scanResponse.card)
override val derivationStyleProvider: DerivationStyleProvider
get() = scanResponse.derivationStyleProvider
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return scanResponse.getWalletPublicKey(curve)
}
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return scanResponse.getDerivedKeys(publicKey)
}
}
}
private fun ScanResponse.getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return card.wallets.firstOrNull { it.curve == curve && it.chainCode != null }
?.publicKey
}
private fun ScanResponse.getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
}

View file

@ -3,45 +3,80 @@ package com.tangem.data.wallets.derivations
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import kotlin.collections.forEach
private typealias DerivationData = Pair<ByteArrayKey, List<DerivationPath>>
internal typealias Derivations = Map<ByteArrayKey, List<DerivationPath>>
/**
* Data class representing a blockchain with its derivation path
*/
data class BlockchainToDerive(
val blockchain: Blockchain,
val derivationPath: DerivationPath,
)
/**
* Finder of missed derivations
*
* @property userWallet User wallet to find derivations for
* @property source Source of derivations data (UserWallet or ScanResponse)
*
[REDACTED_AUTHOR]
*/
internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
class MissedDerivationsFinder private constructor(private val source: DerivationsSource) {
/**
* Secondary constructor for backward compatibility with UserWallet
*/
constructor(userWallet: UserWallet) : this(DerivationsSource.FromUserWallet(userWallet))
/**
* Secondary constructor for ScanResponse
*/
constructor(scanResponse: ScanResponse) : this(DerivationsSource.FromScanResponse(scanResponse))
/** Find missed derivations for given currencies [currencies] */
fun find(currencies: List<CryptoCurrency>): Derivations {
return currencies.map { it.network }.let(::findByNetworks)
}
/** Find missed derivations for given [Network] list */
fun findByNetworks(networks: List<Network>): Derivations {
val blockchainsToDerive = networks.mapNotNull { network ->
val blockchain = network.toBlockchain()
val derivationPath = network.derivationPath.value?.let(::DerivationPath)
?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
return findByBlockchainsToDerive(blockchainsToDerive)
}
/** Find missed derivations for given [BlockchainToDerive] list */
fun findByBlockchainsToDerive(blockchainsToDerive: Collection<BlockchainToDerive>): Derivations {
val enrichedBlockchains = blockchainsToDerive.enrichBlockchains()
return findDerivationsInternal(enrichedBlockchains)
}
/**
* Common implementation for finding derivations
*/
private fun findDerivationsInternal(items: Collection<BlockchainToDerive>): Derivations {
return buildMap<ByteArrayKey, MutableList<DerivationPath>> {
networks
.mapToNewDerivations()
items
.mapNotNull(::mapToNewDerivation)
.forEach { data ->
val current = this[data.first]
if (current != null) {
current.addAll(data.second)
current.distinct()
this[data.first] = current.distinct().toMutableList()
} else {
this[data.first] = data.second.toMutableList()
}
@ -49,31 +84,17 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
}
}
private fun List<Network>.mapToNewDerivations(): List<DerivationData> {
return mapNotNull { network ->
val blockchain = network.toBlockchain()
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@mapNotNull null
/**
* Maps a single BlockchainToDerive to derivation data (public key -> derivation paths)
*/
private fun mapToNewDerivation(input: BlockchainToDerive): DerivationData? {
val curve = source.curvesConfig.primaryCurve(input.blockchain) ?: return null
if (!input.blockchain.getSupportedCurves().contains(curve)) return null
val walletPublicKey = when (userWallet) {
is UserWallet.Cold -> {
val wallet = userWallet.scanResponse.card.wallets.firstOrNull { it.curve == curve }
wallet?.publicKey
}
is UserWallet.Hot -> {
val wallet = userWallet.wallets?.firstOrNull { it.curve == curve }
wallet?.publicKey
}
}
val publicKey = source.getWalletPublicKey(curve) ?: return null
walletPublicKey?.let {
findNewDerivations(curve = curve, publicKey = it, network = network)
}
}
}
private fun findNewDerivations(curve: EllipticCurve, publicKey: ByteArray, network: Network): DerivationData? {
val derivationCandidates = network
.getDerivationCandidates(curve)
val derivationCandidates = input.blockchain
.getDerivationCandidates(input.derivationPath)
.ifEmpty { return null }
.filterAlreadyDerivedKeys(publicKey.toMapKey())
.ifEmpty { return null }
@ -81,59 +102,63 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
return publicKey.toMapKey() to derivationCandidates
}
private fun Network.getDerivationCandidates(curve: EllipticCurve): List<DerivationPath> {
val blockchain = this.toBlockchain()
/**
* Gets all possible derivation paths for a blockchain
*/
private fun Blockchain.getDerivationCandidates(derivationPath: DerivationPath): List<DerivationPath> {
return buildList {
add(blockchain.getDerivationPath(curve = curve))
add(blockchain.getCustomDerivationPath(curve = curve, network = this@getDerivationCandidates))
add(blockchain.getCardanoDerivationPathIfNeeded(network = this@getDerivationCandidates))
// Default derivation path for blockchain
add(getDerivationPath())
// The specified derivation path (can be either default or custom)
add(derivationPath)
// Extended Cardano derivation path if needed
add(getCardanoExtendedDerivationPath(derivationPath))
}
.filterNotNull()
.distinct()
}
private fun Blockchain.getDerivationPath(curve: EllipticCurve): DerivationPath? {
return if (getSupportedCurves().contains(curve)) {
derivationPath(style = userWallet.derivationStyleProvider.getDerivationStyle())
} else {
null
}
private fun Blockchain.getDerivationPath(): DerivationPath? {
return derivationPath(style = source.derivationStyleProvider.getDerivationStyle())
}
private fun Blockchain.getCustomDerivationPath(curve: EllipticCurve, network: Network): DerivationPath? {
return if (getSupportedCurves().contains(curve)) {
network.derivationPath.value?.let(::DerivationPath)
} else {
null
}
}
private fun Blockchain.getCardanoDerivationPathIfNeeded(network: Network): DerivationPath? {
return if (this == Blockchain.Cardano) {
network.derivationPath.value?.let {
CardanoUtils.extendedDerivationPath(derivationPath = DerivationPath(it))
}
} else {
null
}
private fun Blockchain.getCardanoExtendedDerivationPath(customDerivationPath: DerivationPath): DerivationPath? {
if (this != Blockchain.Cardano) return null
return CardanoUtils.extendedDerivationPath(derivationPath = customDerivationPath)
}
private fun List<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val alreadyDerivedPaths = getAlreadyDerivedKeys(publicKey)
val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList()
return filterNot(alreadyDerivedPaths::contains)
}
private fun getAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val extendedPublicKeysMap = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
is UserWallet.Hot -> {
val wallets = userWallet.wallets ?: return emptyList()
wallets.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }?.derivedKeys
?: ExtendedPublicKeysMap(emptyMap())
}
// region Blockchain enrichment logic
/**
* Enriches blockchains collection:
* - Adds Ethereum if HD wallet is allowed
* - Removes unnecessary blockchains that share derivation path with Ethereum (for cards without old style derivation)
*/
private fun Collection<BlockchainToDerive>.enrichBlockchains(): Collection<BlockchainToDerive> {
if (!source.isHDWalletAllowed) return this
val derivationStyle = source.derivationStyleProvider.getDerivationStyle()
val ethereumDerivationPath = Blockchain.Ethereum.derivationPath(derivationStyle) ?: return this
val withEthereum = this + BlockchainToDerive(Blockchain.Ethereum, ethereumDerivationPath)
// For cards with old style derivation, keep all blockchains
if (source.hasOldStyleDerivation) {
return withEthereum.distinct()
}
return extendedPublicKeysMap.keys.toList()
// For new cards: filter out blockchains with same derivation path as Ethereum (except Ethereum itself)
return withEthereum
.filter { it.derivationPath != ethereumDerivationPath || it.blockchain == Blockchain.Ethereum }
.distinct()
}
// endregion
}

View file

@ -14,11 +14,13 @@ import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.configs.MultiWalletCardConfig
import com.tangem.domain.card.configs.Wallet2CardConfig
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import org.junit.Test
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MissedDerivationsFinderTest {
@Test
@ -97,9 +99,8 @@ internal class MissedDerivationsFinderTest {
val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf)
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()),
listOf(
val expected = mapOf(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()) to listOf(
DerivationConfigV2.derivations(Blockchain.Cardano).values.first(),
CardanoUtils.extendedDerivationPath(
derivationPath = DerivationPath(
@ -108,7 +109,12 @@ internal class MissedDerivationsFinderTest {
),
),
),
ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()) to listOf(
DerivationConfigV2.derivations(Blockchain.Ethereum).values.first(),
),
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
}
@Test