Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-27 15:17:39 +03:00
commit b6bf011204
1257 changed files with 31185 additions and 8936 deletions

View file

@ -22,6 +22,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFI
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
@ -36,10 +37,11 @@ import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
internal class DefaultWalletsRepository(
private val appPreferencesStore: AppPreferencesStore,
private val tangemTechApi: TangemTechApi,
@ -49,18 +51,85 @@ internal class DefaultWalletsRepository(
private val authProvider: AuthProvider,
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
MutableStateFlow(mutableSetOf<UserWalletId>())
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
}
@Deprecated("Hot wallet feature makes app always save user wallets. Do not use this method")
override fun shouldSaveUserWallets(): Flow<Boolean> {
return appPreferencesStore.get(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
}
@Deprecated("Hot wallet feature makes app always save user wallets. Do not use this method")
override suspend fun saveShouldSaveUserWallets(item: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, value = item)
}
override suspend fun useBiometricAuthentication(): Boolean {
val useBiometricAuthentication = appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY,
)
if (useBiometricAuthentication != null) {
return useBiometricAuthentication
}
val legacySaveWalletsInTheApp = appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.SAVE_USER_WALLETS_KEY,
)
if (legacySaveWalletsInTheApp != null) {
// Migrate legacy setting to new one
appPreferencesStore.store(
key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY,
value = legacySaveWalletsInTheApp,
)
return legacySaveWalletsInTheApp
} else {
// Default value for new users
setUseBiometricAuthentication(false)
return false
}
}
override suspend fun setUseBiometricAuthentication(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, value = value)
}
override suspend fun requireAccessCode(): Boolean {
val requireAccessCode = appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY,
)
if (requireAccessCode != null) {
return requireAccessCode
}
val legacyShouldSaveAccessCode = appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY,
)
if (legacyShouldSaveAccessCode != null) {
// Migrate legacy setting to new one
appPreferencesStore.store(
key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY,
value = legacyShouldSaveAccessCode.not(),
)
return legacyShouldSaveAccessCode.not()
} else {
// Default value for new users
setRequireAccessCode(true)
return true
}
}
override suspend fun setRequireAccessCode(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, value = value)
}
override suspend fun isWalletWithRing(userWalletId: UserWalletId): Boolean {
return appPreferencesStore
.getSyncOrDefault(key = PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY, default = emptySet())
@ -268,6 +337,16 @@ internal class DefaultWalletsRepository(
}
}
override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow<Boolean> {
return upgradeWalletNotificationDisabled.map {
it.contains(userWalletId)
}
}
override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) {
upgradeWalletNotificationDisabled.update { it.plus(userWalletId) }
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId = walletId,

View file

@ -0,0 +1,153 @@
package com.tangem.data.wallets.cold
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.derivations.Derivations
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
private typealias DerivedKeys = Map<ByteArrayKey, ExtendedPublicKeysMap>
internal class DefaultColdMapDerivationsRepository @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
private val networkFactory: NetworkFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : ColdMapDerivationsRepository {
override suspend fun derivePublicKeys(
userWallet: UserWallet.Cold,
currencies: List<CryptoCurrency>,
): UserWallet.Cold = withContext(dispatchers.io) {
derivePublicKeysByNetworks(userWallet = userWallet, networks = currencies.map(CryptoCurrency::network))
}
override suspend fun derivePublicKeysByNetworkIds(
userWallet: UserWallet.Cold,
networkIds: List<Network.RawID>,
): UserWallet.Cold = withContext(dispatchers.io) {
derivePublicKeysByNetworks(
userWallet = userWallet,
networks = networkIds.mapNotNull {
networkFactory.create(
blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null,
extraDerivationPath = null,
userWallet = userWallet,
)
},
)
}
override suspend fun derivePublicKeysByNetworks(
userWallet: UserWallet.Cold,
networks: List<Network>,
): UserWallet.Cold = withContext(dispatchers.io) {
if (!userWallet.scanResponse.card.settings.isHDWalletAllowed) {
Timber.d("Nothing to derive")
return@withContext userWallet
}
val derivations = MissedDerivationsFinder(userWallet)
.findByNetworks(networks)
.ifEmpty {
Timber.d("Nothing to derive")
return@withContext userWallet
}
return@withContext derivePublicKeys(userWallet = userWallet, derivations = derivations).first
}
override suspend fun derivePublicKeys(
userWallet: UserWallet.Cold,
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Pair<UserWallet.Cold, Map<ByteArrayKey, ExtendedPublicKeysMap>> = withContext(dispatchers.io) {
// todo replace it in task [REDACTED_JIRA]
val preflightReadFilter = UserWalletIdPreflightReadFilter(userWallet.walletId)
val result = tangemSdkManager.derivePublicKeys(
cardId = null,
derivations = derivations,
preflightReadFilter = preflightReadFilter,
)
when (result) {
is CompletionResult.Success -> {
userWallet.updateDerivedKeys(result.data.entries).also {
validateDerivations(scanResponse = it.scanResponse, derivations = derivations)
} to result.data.entries
}
is CompletionResult.Failure -> {
throw result.error
}
}
}
override suspend fun hasMissedDerivations(
userWallet: UserWallet.Cold,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean = withContext(dispatchers.io) {
val derivations =
MissedDerivationsFinder(userWallet)
.findByNetworks(
networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) ->
networkFactory.create(
blockchain = Blockchain.fromNetworkId(backendId) ?: return@mapNotNull null,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
)
},
)
derivations.isNotEmpty()
}
/**
* It throws an exception if any of the provided derivations are invalid
* Validation for NonHardened moved to application layer, to avoid fails when derive multiple paths
* It needs to be called after success [derivePublicKeys] or in same flows
*/
private fun validateDerivations(scanResponse: ScanResponse, derivations: Derivations) {
derivations.entries.forEach { derivationForKey ->
val wallet = scanResponse.card.wallets.firstOrNull { it.publicKey.toMapKey() == derivationForKey.key }
if (wallet == null) return@forEach
val hasHardenedNodes = derivationForKey.value.any { path -> path.nodes.any { node -> !node.isHardened } }
if (wallet.curve == EllipticCurve.Ed25519Slip0010 && hasHardenedNodes) {
throw TangemSdkError.NonHardenedDerivationNotSupported()
}
}
}
private fun UserWallet.Cold.updateDerivedKeys(keys: DerivedKeys): UserWallet.Cold {
return copy(
scanResponse = scanResponse.copy(
derivedKeys = getUpdatedDerivedKeys(oldKeys = scanResponse.derivedKeys, newKeys = keys),
),
)
}
private fun getUpdatedDerivedKeys(oldKeys: DerivedKeys, newKeys: DerivedKeys): DerivedKeys {
return (oldKeys.keys + newKeys.keys).toSet()
.associateWith { walletKey ->
val oldDerivations = ExtendedPublicKeysMap(oldKeys[walletKey] ?: emptyMap())
val newDerivations = newKeys[walletKey] ?: ExtendedPublicKeysMap(emptyMap())
ExtendedPublicKeysMap(oldDerivations + newDerivations)
}
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.wallets.cold
import com.tangem.common.card.Card
import com.tangem.common.core.SessionEnvironment
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.operations.preflightread.PreflightReadFilter
/**
* [PreflightReadFilter] for checking if card has expected user wallet id
*
[REDACTED_AUTHOR]
*/
class UserWalletIdPreflightReadFilter(private val expectedUserWalletId: UserWalletId) : PreflightReadFilter {
override fun onCardRead(card: Card, environment: SessionEnvironment) = Unit
override fun onFullCardRead(card: Card, environment: SessionEnvironment) {
val actualUserWalletId = UserWalletIdBuilder.card(card = CardDTO(card)).build() ?: return
if (expectedUserWalletId != actualUserWalletId) throw TangemSdkError.WalletNotFound()
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.data.wallets.derivations
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.map
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultDerivationsRepository @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val hotDerivationsRepository: HotMapDerivationsRepository,
private val coldDerivationsRepository: ColdMapDerivationsRepository,
private val dispatchers: CoroutineDispatcherProvider,
) : DerivationsRepository {
override suspend fun derivePublicKeys(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
derivePublicKeysByNetworks(userWalletId = userWalletId, networks = currencies.map(CryptoCurrency::network))
}
override suspend fun derivePublicKeysByNetworkIds(userWalletId: UserWalletId, networkIds: List<Network.RawID>) {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.derivePublicKeysByNetworkIds(userWallet, networkIds)
is UserWallet.Hot -> hotDerivationsRepository.derivePublicKeysByNetworkIds(userWallet, networkIds)
}.also {
userWallet.update(it)
}
}
override suspend fun derivePublicKeysByNetworks(userWalletId: UserWalletId, networks: List<Network>) {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.derivePublicKeysByNetworks(userWallet, networks)
is UserWallet.Hot -> hotDerivationsRepository.derivePublicKeysByNetworks(userWallet, networks)
}.also {
userWallet.update(it)
}
}
override suspend fun derivePublicKeys(
userWalletId: UserWalletId,
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Map<ByteArrayKey, ExtendedPublicKeysMap> {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
return when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.derivePublicKeys(userWallet, derivations)
is UserWallet.Hot -> hotDerivationsRepository.derivePublicKeys(userWallet, derivations)
}.let {
userWallet.update(it.first)
it.second
}
}
override suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean {
return when (val userWallet = userWalletsStore.getSyncStrict(userWalletId)) {
is UserWallet.Cold -> coldDerivationsRepository.hasMissedDerivations(userWallet, networksWithDerivationPath)
is UserWallet.Hot -> hotDerivationsRepository.hasMissedDerivations(userWallet, networksWithDerivationPath)
}
}
private suspend fun UserWallet.update(newUserWallet: UserWallet) = withContext(dispatchers.io) {
check(this@update.walletId == newUserWallet.walletId) {
"Cannot update UserWallet with different walletId: ${newUserWallet.walletId}"
}
if (this@update == newUserWallet) {
return@withContext // No update needed
}
val updateResult = userWalletsStore.update(
userWalletId = newUserWallet.walletId,
update = { userWalletToUpdate -> newUserWallet },
)
when (updateResult) {
is CompletionResult.Failure -> throw updateResult.error
is CompletionResult.Success -> updateResult.data
}
}
}

View file

@ -0,0 +1,139 @@
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.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>>
/**
* Finder of missed derivations
*
* @property userWallet User wallet to find derivations for
*
[REDACTED_AUTHOR]
*/
internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
/** Find missed derivations for given currencies [currencies] */
fun find(currencies: List<CryptoCurrency>): Derivations {
return currencies.map { it.network }.let(::findByNetworks)
}
fun findByNetworks(networks: List<Network>): Derivations {
return buildMap<ByteArrayKey, MutableList<DerivationPath>> {
networks
.mapToNewDerivations()
.forEach { data ->
val current = this[data.first]
if (current != null) {
current.addAll(data.second)
current.distinct()
} else {
this[data.first] = data.second.toMutableList()
}
}
}
}
private fun List<Network>.mapToNewDerivations(): List<DerivationData> {
return mapNotNull { network ->
val blockchain = network.toBlockchain()
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@mapNotNull 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
}
}
walletPublicKey?.let {
findNewDerivations(curve = curve, publicKey = it, network = network)
}
}
}
private fun findNewDerivations(curve: EllipticCurve, publicKey: ByteArray, network: Network): DerivationData? {
val derivationCandidates = network
.getDerivationCandidates(curve)
.ifEmpty { return null }
.filterAlreadyDerivedKeys(publicKey.toMapKey())
.ifEmpty { return null }
return publicKey.toMapKey() to derivationCandidates
}
private fun Network.getDerivationCandidates(curve: EllipticCurve): List<DerivationPath> {
val blockchain = this.toBlockchain()
return buildList {
add(blockchain.getDerivationPath(curve = curve))
add(blockchain.getCustomDerivationPath(curve = curve, network = this@getDerivationCandidates))
add(blockchain.getCardanoDerivationPathIfNeeded(network = this@getDerivationCandidates))
}
.filterNotNull()
.distinct()
}
private fun Blockchain.getDerivationPath(curve: EllipticCurve): DerivationPath? {
return if (getSupportedCurves().contains(curve)) {
derivationPath(style = userWallet.derivationStyleProvider.getDerivationStyle())
} else {
null
}
}
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 List<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val alreadyDerivedPaths = getAlreadyDerivedKeys(publicKey)
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())
}
}
return extendedPublicKeysMap.keys.toList()
}
}

View file

@ -2,14 +2,23 @@ package com.tangem.data.wallets.di
import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository
import com.tangem.data.wallets.DefaultWalletsRepository
import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository
import com.tangem.data.wallets.derivations.DefaultDerivationsRepository
import com.tangem.data.wallets.hot.DefaultHotMapDerivationsRepository
import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -44,4 +53,27 @@ internal object WalletsDataModule {
fun provideMigrateNamesRepository(appPreferencesStore: AppPreferencesStore): WalletNamesMigrationRepository {
return DefaultWalletNamesMigrationRepository(appPreferencesStore)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface WalletsDataBindsModule {
@Binds
@Singleton
fun bindDerivationsRepository(impl: DefaultDerivationsRepository): DerivationsRepository
@Binds
@Singleton
fun bindHotMapDerivationsRepository(impl: DefaultHotMapDerivationsRepository): HotMapDerivationsRepository
@Binds
@Singleton
fun bindColdMapDerivationsRepository(impl: DefaultColdMapDerivationsRepository): ColdMapDerivationsRepository
@Binds
@Singleton
fun bindHotWalletAccessCodeAttemptsRepository(
impl: DefaultHotWalletAccessCodeAttemptsRepository,
): HotWalletAccessCodeAttemptsRepository
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.wallets.hot
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
internal class DefaultHotMapDerivationsRepository @Inject constructor(
private val networkFactory: NetworkFactory,
private val hotWalletAccessor: HotWalletAccessor,
private val dispatchers: CoroutineDispatcherProvider,
) : HotMapDerivationsRepository {
override suspend fun derivePublicKeys(
userWallet: UserWallet.Hot,
currencies: List<CryptoCurrency>,
): UserWallet.Hot {
return derivePublicKeysByNetworks(userWallet = userWallet, networks = currencies.map(CryptoCurrency::network))
}
override suspend fun derivePublicKeysByNetworkIds(
userWallet: UserWallet.Hot,
networkIds: List<Network.RawID>,
): UserWallet.Hot {
return derivePublicKeysByNetworks(
userWallet = userWallet,
networks = networkIds.mapNotNull {
networkFactory.create(
blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null,
extraDerivationPath = null,
userWallet = userWallet,
)
},
)
}
override suspend fun derivePublicKeysByNetworks(
userWallet: UserWallet.Hot,
networks: List<Network>,
): UserWallet.Hot = withContext(dispatchers.default) {
val derivations = MissedDerivationsFinder(userWallet)
.findByNetworks(networks)
.ifEmpty {
Timber.d("Nothing to derive")
return@withContext userWallet
}
derivePublicKeys(userWallet, derivations).first
}
override suspend fun derivePublicKeys(
userWallet: UserWallet.Hot,
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Pair<UserWallet.Hot, Map<ByteArrayKey, ExtendedPublicKeysMap>> {
val wallets = userWallet.wallets ?: return userWallet to emptyMap()
val request = DeriveWalletRequest(
derivations.map { entry ->
val wallet = wallets.first { it.publicKey.contentEquals(entry.key.bytes) }
DeriveWalletRequest.Request(
curve = wallet.curve,
paths = entry.value,
)
},
)
val result = hotWalletAccessor.derivePublicKeys(
hotWalletId = userWallet.hotWalletId,
request = request,
)
val newKeys =
result.responses.associate { ByteArrayKey(it.seedKey.publicKey) to ExtendedPublicKeysMap(it.publicKeys) }
return userWallet.updateWithNewKeys(newKeys) to newKeys
}
override suspend fun hasMissedDerivations(
userWallet: UserWallet.Hot,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean = withContext(dispatchers.default) {
val derivations = MissedDerivationsFinder(userWallet)
.findByNetworks(
networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) ->
networkFactory.create(
blockchain = Blockchain.fromNetworkId(backendId) ?: return@mapNotNull null,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
)
},
)
derivations.isNotEmpty()
}
private fun UserWallet.Hot.updateWithNewKeys(newKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>): UserWallet.Hot {
val wallets = this.wallets ?: return this
val derivedKeys = wallets.associate {
it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys)
}
val updatedKeys = getUpdatedDerivedKeys(
oldKeys = derivedKeys,
newKeys = newKeys,
)
return copy(
wallets = wallets.map { wallet ->
wallet.copy(
derivedKeys = updatedKeys[wallet.publicKey.toMapKey()] ?: ExtendedPublicKeysMap(emptyMap()),
)
},
)
}
private fun getUpdatedDerivedKeys(
oldKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
newKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
): Map<ByteArrayKey, ExtendedPublicKeysMap> {
return (oldKeys.keys + newKeys.keys).toSet()
.associateWith { walletKey ->
val oldDerivations = ExtendedPublicKeysMap(oldKeys[walletKey] ?: emptyMap())
val newDerivations = newKeys[walletKey] ?: ExtendedPublicKeysMap(emptyMap())
ExtendedPublicKeysMap(oldDerivations + newDerivations)
}
}
}

View file

@ -0,0 +1,138 @@
package com.tangem.data.wallets.hot
import android.content.Context
import android.os.SystemClock
import android.provider.Settings
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.ATTEMPTS_BEFORE_DELETION
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.COOLDOWN_SECONDS
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_ATTEMPTS_BEFORE_DELETION
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS
import com.tangem.hot.sdk.model.HotWalletId
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Suppress("MagicNumber")
class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val appPreferencesStore: AppPreferencesStore,
) : HotWalletAccessCodeAttemptsRepository {
override suspend fun incrementAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) {
val attemptsKey = PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())
appPreferencesStore.editData { preferences ->
val currentAttempts = preferences[attemptsKey] ?: 0
val newAttempts = currentAttempts + 1
preferences[attemptsKey] = newAttempts
val currentBootCount = currentBootCount()
preferences[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] = currentBootCount
if (newAttempts >= MAX_FAST_FORWARD_ATTEMPTS) {
val currentDeadline = SystemClock.elapsedRealtime() + COOLDOWN_SECONDS * 1000
preferences[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] = currentDeadline
}
}
}
override suspend fun resetAttempts(hotWalletId: HotWalletId) {
val authAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
hotWalletId = hotWalletId,
auth = true,
)
val noAuthAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
hotWalletId = hotWalletId,
auth = false,
)
appPreferencesStore.editData {
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey()))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Flow<Attempts> {
val flow = appPreferencesStore.data.map {
AttemptsPersistentData(
attempts = it[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0,
bootCount = it[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0,
deadline = it[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L,
)
}.distinctUntilChanged()
return flow.transformLatest {
while (true) {
emit(toState(id, it.attempts, it.deadline, it.bootCount))
val remaining = remainingSeconds(it.deadline, it.bootCount)
if (remaining <= 0) break
delay(timeMillis = 1000)
}
}.distinctUntilChanged()
}
override suspend fun getAttemptsSync(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Attempts {
val prefs = appPreferencesStore.data.first()
val count = prefs[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0
val boot = prefs[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0
val deadline = prefs[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L
return toState(id, count, deadline, boot)
}
private fun remainingSeconds(deadline: Long, bootStored: Int): Int {
val now = SystemClock.elapsedRealtime()
val bootNow = currentBootCount()
if (bootNow != bootStored) {
// If the boot happened after the last attempt, we consider timer to start from the beginning
return maxOf(0, COOLDOWN_SECONDS - (now / 1000).toInt())
}
return maxOf(0, ((deadline - now) / 1000).toInt())
}
private fun toState(
id: HotWalletAccessCodeAttemptsRepository.AttemptId,
count: Int,
deadlineElapsed: Long,
bootStored: Int,
): Attempts {
val fast = MAX_FAST_FORWARD_ATTEMPTS
val attention = ATTEMPTS_BEFORE_DELETION
val deletion = MAX_ATTEMPTS_BEFORE_DELETION
return when {
count < fast -> Attempts.FastForward(count)
id.auth && count >= deletion -> Attempts.Deletion
id.auth && count >= attention -> {
val remaining = remainingSeconds(deadlineElapsed, bootStored)
Attempts.BeforeDeletion(count, remaining, deletion - count)
}
else -> {
val remaining = remainingSeconds(deadlineElapsed, bootStored)
Attempts.WithDelay(count, remaining)
}
}
}
private fun HotWalletAccessCodeAttemptsRepository.AttemptId.attemptIdKey(): String {
return "${hotWalletId.value}_$auth"
}
private fun currentBootCount(): Int = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0)
private data class AttemptsPersistentData(
val attempts: Int,
val bootCount: Int,
val deadline: Long,
)
}

View file

@ -0,0 +1,175 @@
package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.copy
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import javax.inject.Inject
class HotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
) {
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign)
}
suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
val isAccessCodeRequired = walletsRepository.requireAccessCode()
val auth = when (hotWalletId.authType) {
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth
HotWalletId.AuthType.Password -> requestPassword(
hotWalletId = hotWalletId,
hasBiometry = false,
)
HotWalletId.AuthType.Biometry -> {
if (isAccessCodeRequired) {
requestPassword(
hotWalletId = hotWalletId,
hasBiometry = false,
)
} else {
HotAuth.Biometry
}
}
}
return runCatchingSdkErrors(hotWalletId, auth) {
block(UnlockHotWallet(hotWalletId, it)).also {
hotWalletPasswordRequester.dismiss()
}
}
}
private suspend fun <T> runCatchingSdkErrors(
hotWalletId: HotWalletId,
auth: HotAuth,
block: suspend (auth: HotAuth) -> T,
): T {
return runCatchingWrongPassInternal(
hotWalletId = hotWalletId,
originalAuth = auth,
auth = auth,
block = { blockAuth ->
block(blockAuth).also {
// Update biometry auth if the original auth was password
updateBiometryAuthIfNeeded(
hotWalletId = hotWalletId,
originalAuth = blockAuth,
)
}
},
)
}
private suspend fun updateBiometryAuthIfNeeded(hotWalletId: HotWalletId, originalAuth: HotAuth) {
val isAccessCodeRequired = walletsRepository.requireAccessCode()
if (originalAuth is HotAuth.Password && isAccessCodeRequired.not()) {
val userWallet = userWalletsListRepository.userWalletsSync()
.find { it is UserWallet.Hot && it.hotWalletId == hotWalletId }
as? UserWallet.Hot
?: return
val newHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = UnlockHotWallet(
walletId = hotWalletId,
auth = originalAuth,
),
auth = HotAuth.Biometry,
)
userWalletsListRepository.saveWithoutLock(
userWallet = userWallet.copy(
hotWalletId = newHotWalletId,
),
canOverride = true,
)
}
}
private suspend fun <T> runCatchingWrongPassInternal(
hotWalletId: HotWalletId,
originalAuth: HotAuth,
auth: HotAuth,
block: suspend (auth: HotAuth) -> T,
): T = runCatching {
block(auth)
}.getOrElse { exception ->
if (auth is HotAuth.Biometry && exception.isBiometryError()) {
// fallback to password if biometry fails
val passAuth = requestPassword(
hotWalletId = hotWalletId,
hasBiometry = true,
)
return@getOrElse runCatchingWrongPassInternal(
hotWalletId = hotWalletId,
originalAuth = originalAuth,
auth = passAuth,
block = block,
)
}
if (exception !is WrongPasswordException) {
throw exception
}
// If the exception is a wrong password, we need to request the password again
hotWalletPasswordRequester.wrongPassword()
val passResult = requestPassword(
hotWalletId = hotWalletId,
hasBiometry = originalAuth is HotAuth.Biometry,
)
runCatchingWrongPassInternal(
hotWalletId = hotWalletId,
originalAuth = originalAuth,
auth = passResult,
block = block,
)
}
private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): HotAuth {
val attemptRequest = HotWalletPasswordRequester.AttemptRequest(
hotWalletId = hotWalletId,
authMode = false,
hasBiometry = hasBiometry,
)
return hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth()
?: throw TangemSdkError.UserCancelled()
}
private fun Throwable.isBiometryError(): Boolean {
return this is TangemSdkError.AuthenticationFailed ||
this is TangemSdkError.AuthenticationCanceled ||
this is TangemSdkError.AuthenticationLockout ||
this is TangemSdkError.AuthenticationUnavailable ||
this is TangemSdkError.AuthenticationAlreadyInProgress ||
this is TangemSdkError.AuthenticationNotInitialized ||
this is TangemSdkError.AuthenticationPermanentLockout
}
private fun HotWalletPasswordRequester.Result.toAuth() = when (this) {
HotWalletPasswordRequester.Result.UseBiometry -> HotAuth.Biometry
HotWalletPasswordRequester.Result.Dismiss -> null
is HotWalletPasswordRequester.Result.EnteredPassword -> this.password
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.data.wallets.hot
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.map
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.operations.sign.SignData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import timber.log.Timber
class TangemHotWalletSigner @AssistedInject constructor(
@Assisted private val userWallet: UserWallet.Hot,
private val hotWalletAccessor: HotWalletAccessor,
) : TransactionSigner {
override suspend fun sign(hash: ByteArray, publicKey: Wallet.PublicKey): CompletionResult<ByteArray> {
return sign(listOf(hash), publicKey).map { it.first() }
}
override suspend fun sign(
hashes: List<ByteArray>,
publicKey: Wallet.PublicKey,
): CompletionResult<List<ByteArray>> {
val wallet = userWallet.wallets.orEmpty().firstOrNull { it.publicKey.contentEquals(publicKey.seedKey) }
?: return CompletionResult.Failure(
TangemSdkError.ExceptionError(IllegalStateException("wallet is locked")),
)
val result = runCatching {
hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = listOf(
DataToSign(
curve = wallet.curve,
hashes = hashes,
derivationPath = publicKey.derivationPath,
),
),
)
}.getOrElse {
Timber.e(it)
return if (it is TangemSdkError) {
CompletionResult.Failure(it)
} else {
CompletionResult.Failure(TangemSdkError.ExceptionError(it))
}
}
return CompletionResult.Success(result.map { it.signatures }.flatten())
}
override suspend fun multiSign(
dataToSign: List<SignData>,
publicKey: Wallet.PublicKey,
): CompletionResult<Map<ByteArray, ByteArray>> {
val result = runCatching {
hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = dataToSign.map { signData ->
val wallet =
userWallet.wallets.orEmpty().firstOrNull { it.publicKey.contentEquals(signData.publicKey) }
?: return CompletionResult.Failure(
TangemSdkError.ExceptionError(IllegalStateException("wallet is locked")),
)
DataToSign(
curve = wallet.curve,
hashes = listOf(signData.hash),
derivationPath = signData.derivationPath,
)
},
)
}.getOrElse {
Timber.e(it)
return if (it is TangemSdkError) {
CompletionResult.Failure(it)
} else {
CompletionResult.Failure(TangemSdkError.ExceptionError(it))
}
}
return CompletionResult.Success(
result.mapIndexed { index, data ->
dataToSign[index].publicKey to data.signatures.first()
}.toMap(),
)
}
@AssistedFactory
interface Factory {
fun create(@Assisted userWallet: UserWallet.Hot): TangemHotWalletSigner
}
}

View file

@ -199,7 +199,7 @@ class DefaultWalletsRepositoryTest {
)
val authProvider = mockk<AuthProvider> {
every { getCardsPublicKeys() } returns publicKeys
coEvery { getCardsPublicKeys() } returns publicKeys
}
repository = DefaultWalletsRepository(

View file

@ -0,0 +1,177 @@
package com.tangem.data.wallets.derivations
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.CompletionResult
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.ScanCardException
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.configs.MultiWalletCardConfig
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultDerivationsRepositoryTest {
private val tangemSdkManager = mockk<TangemSdkManager>()
private val userWalletsStore = mockk<UserWalletsStore>()
private val repository = DefaultDerivationsRepository(
userWalletsStore = userWalletsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
hotDerivationsRepository = mockk(),
coldDerivationsRepository = DefaultColdMapDerivationsRepository(
tangemSdkManager = tangemSdkManager,
networkFactory = NetworkFactory(excludedBlockchains = ExcludedBlockchains()),
dispatchers = TestingCoroutineDispatcherProvider(),
),
)
private val defaultUserWalletId = UserWalletId("011")
private val defaultUserWallet = UserWallet.Cold(
name = "",
walletId = defaultUserWalletId,
cardsInWallet = setOf(),
isMultiCurrency = false,
scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()),
hasBackupError = false,
)
@Test
fun `error if userWalletId not found`() = runTest {
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } throws IllegalStateException()
runCatching {
repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList())
}
.onSuccess { error("Should throws exception") }
.onFailure { Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java) }
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) }
}
@SuppressLint("CheckResult")
@Test
fun `success if card is not supported derivations`() = runTest {
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } returns defaultUserWallet
repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList())
runCatching { }
.onSuccess { Truth.assertThat(it) }
.onFailure {
error("Should returns success")
}
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) }
}
@SuppressLint("CheckResult")
@Test
fun `success if currencies is empty`() = runTest {
val userWallet = defaultUserWallet.copy(
scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()),
)
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } returns userWallet
runCatching { repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList()) }
.onSuccess { Truth.assertThat(it) }
.onFailure { error("Should returns success") }
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) }
}
@SuppressLint("CheckResult")
@Test
fun `success if card already has derivations`() = runTest {
val userWallet = defaultUserWallet.copy(
scanResponse = MockScanResponseFactory.create(
cardConfig = MultiWalletCardConfig,
derivedKeys = DerivedKeysMocks.ethereumDerivedKeys,
),
)
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } returns userWallet
runCatching {
repository.derivePublicKeys(
userWalletId = defaultUserWalletId,
currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf),
)
}
.onSuccess { Truth.assertThat(it) }
.onFailure { error("Should returns success") }
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) }
}
@Test
fun `error if tangemSdkManager throws exception`() = runTest {
val userWallet = defaultUserWallet.copy(
scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()),
)
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } returns userWallet
coEvery { tangemSdkManager.derivePublicKeys(null, any(), any()) } throws ScanCardException.UserCancelled
runCatching {
repository.derivePublicKeys(
userWalletId = defaultUserWalletId,
currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf),
)
}
.onSuccess { error("Should throws exception") }
.onFailure { Truth.assertThat(it).isInstanceOf(ScanCardException.UserCancelled::class.java) }
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(exactly = 1) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) }
}
@SuppressLint("CheckResult")
@Test
fun `success case`() = runTest {
val userWallet = defaultUserWallet.copy(
scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()),
)
coEvery { userWalletsStore.getSyncStrict(defaultUserWalletId) } returns userWallet
coEvery { tangemSdkManager.derivePublicKeys(null, any(), any()) } returns CompletionResult.Success(
DerivationTaskResponse(DerivedKeysMocks.ethereumDerivedKeys),
)
coEvery { userWalletsStore.update(defaultUserWalletId, any()) } returns CompletionResult.Success(userWallet)
runCatching {
repository.derivePublicKeys(
userWalletId = defaultUserWalletId,
currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf),
)
}
.onSuccess { Truth.assertThat(it) }
.onFailure { error("Should returns success but $it") }
coVerify(exactly = 1) { userWalletsStore.getSyncStrict(defaultUserWalletId) }
coVerify(exactly = 1) { tangemSdkManager.derivePublicKeys(null, any(), any()) }
coVerify(exactly = 1) { userWalletsStore.update(defaultUserWalletId, any()) }
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.wallets.derivations
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationConfigV2
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.operations.derivation.ExtendedPublicKeysMap
/**
[REDACTED_AUTHOR]
*/
internal object DerivedKeysMocks {
val ethereumDerivedKeys = mapOf(
EllipticCurve.Secp256k1.name.toByteArray().toMapKey() to ExtendedPublicKeysMap(
mapOf(
DerivationConfigV2.derivations(Blockchain.Ethereum).values.first() to ExtendedPublicKey(
publicKey = ByteArray(0),
chainCode = ByteArray(0),
depth = 2646,
parentFingerprint = ByteArray(0),
childNumber = 1142,
),
),
),
)
}

View file

@ -0,0 +1,146 @@
package com.tangem.data.wallets.derivations
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.DerivationConfigV2
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.crypto.hdWallet.DerivationPath
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
/**
[REDACTED_AUTHOR]
*/
internal class MissedDerivationsFinderTest {
@Test
fun `empty derivations for empty currencies`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val actual = finder.find(emptyList())
Truth.assertThat(actual).isEmpty()
}
@Test
fun `empty derivations for non supported blockchains`() {
// Bls is not supported
val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).chia.let(::listOf)
val actual = finder.find(currencies)
Truth.assertThat(actual).isEmpty()
}
@Test
fun `derivations ONLY for supported blockchains`() {
// Bls is not supported
val scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(2),
derivedKeys = emptyMap(),
).let {
it.copy(
card = it.card.copy(
settings = it.card.settings.copy(isHDWalletAllowed = true, isBackupAllowed = true),
),
)
}
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).chiaAndEthereum
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()),
listOf(DerivationConfigV2.derivations(Blockchain.Ethereum).values.first()),
)
}
@Test
fun `derivations for custom token`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereumTokenWithBinanceDerivation
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()),
listOf(
DerivationConfigV2.derivations(Blockchain.Ethereum).values.first(),
DerivationConfigV2.derivations(Blockchain.Binance).values.first(),
),
)
}
@Test
fun `derivations for cardano`() {
val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap())
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf)
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()),
listOf(
DerivationConfigV2.derivations(Blockchain.Cardano).values.first(),
CardanoUtils.extendedDerivationPath(
derivationPath = DerivationPath(
Blockchain.Cardano.derivationPath(scanResponse.derivationStyleProvider.getDerivationStyle())!!
.rawPath,
),
),
),
)
}
@Test
fun `empty derivations for already derived currencies`() {
val scanResponse = MockScanResponseFactory.create(
cardConfig = Wallet2CardConfig,
derivedKeys = DerivedKeysMocks.ethereumDerivedKeys,
)
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereum.let(::listOf)
val actual = finder.find(currencies)
Truth.assertThat(actual).isEmpty()
}
@Test
fun `derivations ONLY for never derived currencies`() {
val scanResponse = MockScanResponseFactory.create(
cardConfig = MultiWalletCardConfig,
derivedKeys = DerivedKeysMocks.ethereumDerivedKeys,
)
val userWallet = MockUserWalletFactory.create(scanResponse)
val finder = MissedDerivationsFinder(userWallet)
val currencies = MockCryptoCurrencyFactory(userWallet).ethereumAndStellar
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()),
listOf(DerivationConfigV2.derivations(Blockchain.Stellar).values.first()),
)
}
}