Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-04 20:09:55 +03:00
commit a33afd3583
684 changed files with 8006 additions and 4239 deletions

View file

@ -6,11 +6,11 @@ import com.tangem.blockchain.common.FeePaidCurrency
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.card.DerivationStyleProvider
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.util.derivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import timber.log.Timber
import javax.inject.Inject
@ -111,6 +111,7 @@ class NetworkFactory @Inject constructor(
hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource,
canHandleTokens = canHandleTokens,
transactionExtrasType = blockchain.getSupportedTransactionExtras(),
nameResolvingType = blockchain.getNameResolvingType(),
)
}
.getOrNull()
@ -328,6 +329,13 @@ class NetworkFactory @Inject constructor(
}
}
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
return when (this) {
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
else -> Network.NameResolvingType.NONE
}
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun createNetworkStandardType(blockchain: Blockchain) = getNetworkStandardType(blockchain)

View file

@ -7,10 +7,10 @@ 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.common.test.utils.ProvideTestModels
import com.tangem.domain.card.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.configs.MultiWalletCardConfig
import com.tangem.domain.card.common.util.derivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.legacy)

View file

@ -22,8 +22,8 @@ import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.repository.CustomTokensRepository
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.models.wallet.requireColdWallet
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
@ -247,26 +247,43 @@ internal class DefaultCustomTokensRepository(
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while getting supported networks"
}
val scanResponse = userWallet.requireColdWallet().scanResponse // TODO [REDACTED_TASK_KEY]
Blockchain.entries
.mapNotNull { blockchain ->
val canHandleBlockchain = scanResponse.card.canHandleBlockchain(
blockchain,
scanResponse.cardTypesResolver,
excludedBlockchains,
)
when (userWallet) {
is UserWallet.Hot -> {
Blockchain.entries.mapNotNull {
// TODO: refactor [REDACTED_JIRA]\
if (it.isTestnet() || it in excludedBlockchains) return@mapNotNull null
if (canHandleBlockchain) {
networkFactory.create(
blockchain = blockchain,
blockchain = it,
extraDerivationPath = null,
userWallet = userWallet,
)
} else {
null
}
}
is UserWallet.Cold -> {
val scanResponse = userWallet.scanResponse
Blockchain.entries
.mapNotNull { blockchain ->
val canHandleBlockchain = scanResponse.card.canHandleBlockchain(
blockchain,
scanResponse.cardTypesResolver,
excludedBlockchains,
)
if (canHandleBlockchain) {
networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
} else {
null
}
}
}
}
}
override fun createDerivationPath(rawPath: String): Network.DerivationPath {

View file

@ -197,11 +197,10 @@ internal class DefaultManageTokensRepository(
)
private fun getSupportedBlockchains(userWallet: UserWallet?): List<Blockchain> {
return (userWallet as? UserWallet.Cold)?.scanResponse?.let {
it.card.supportedBlockchains(it.cardTypesResolver, excludedBlockchains) // TODO [REDACTED_TASK_KEY]
} ?: Blockchain.entries.filter {
!it.isTestnet() && it !in excludedBlockchains
}
return userWallet?.supportedBlockchains(excludedBlockchains)
?: Blockchain.entries.filter {
!it.isTestnet() && it !in excludedBlockchains
}
}
// endregion

View file

@ -13,14 +13,14 @@ import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.models.TestnetTokensConfig
import com.tangem.domain.card.DerivationStyleProvider
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.util.derivationStyleProvider
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
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.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import timber.log.Timber
internal class ManagedCryptoCurrencyFactory(

View file

@ -17,12 +17,10 @@ import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
@ -542,11 +540,10 @@ internal class DefaultNFTRepository @Inject constructor(
}
private fun Network.canHandleNFTs(userWalletId: UserWalletId): Boolean {
// TODO [REDACTED_TASK_KEY]
val scanResponse = userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().scanResponse
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
val blockchain = Blockchain.fromNetworkId(backendId) ?: return false
return blockchain.canHandleNFTs() &&
scanResponse.card.canHandleToken(blockchain, scanResponse.cardTypesResolver, excludedBlockchains)
userWallet.canHandleToken(blockchain, excludedBlockchains)
}
}

View file

@ -1,48 +1,24 @@
package com.tangem.data.notifications
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
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.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultNotificationsRepository @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appInfoProvider: AppInfoProvider,
class DefaultNotificationsRepository @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : NotificationsRepository {
override suspend fun createApplicationId(pushToken: String?): ApplicationId = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform.lowercase(),
device = appInfoProvider.device,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
pushToken = pushToken,
),
).getOrThrow().appId.let(::ApplicationId)
override suspend fun shouldShowNotification(key: String): Boolean {
return appPreferencesStore.getSyncOrDefault(PreferencesKeys.getShouldShowNotificationKey(key), true)
}
override suspend fun saveApplicationId(appId: ApplicationId) {
appPreferencesStore.store(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY, appId.value)
}
override suspend fun getApplicationId(): ApplicationId? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY)
?.let(::ApplicationId)
override suspend fun setShouldShowNotifications(key: String, value: Boolean) {
appPreferencesStore.store(PreferencesKeys.getShouldShowNotificationKey(key), value)
}
override suspend fun incrementTronTokenFeeNotificationShowCounter() {
@ -62,27 +38,6 @@ internal class DefaultNotificationsRepository @Inject constructor(
)
}
override suspend fun sendPushToken(appId: ApplicationId, pushToken: String) {
withContext(dispatchers.io) {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
),
).getOrThrow()
}
}
override suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork> = withContext(dispatchers.io) {
tangemTechApi.getEligibleNetworksForPushNotifications().getOrThrow().mapNotNull {
NotificationsEligibleNetworkConverter.convert(it)
}
}
override suspend fun shouldShowSubscribeOnNotificationsAfterUpdate(): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.NOTIFICATIONS_USER_ALLOW_SEND_ADDRESSES_KEY,

View file

@ -0,0 +1,68 @@
package com.tangem.data.notifications
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultPushNotificationsRepository @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appInfoProvider: AppInfoProvider,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : PushNotificationsRepository {
override suspend fun createApplicationId(pushToken: String?): ApplicationId = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform.lowercase(),
device = appInfoProvider.device,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
pushToken = pushToken,
),
).getOrThrow().appId.let(::ApplicationId)
}
override suspend fun saveApplicationId(appId: ApplicationId) {
appPreferencesStore.store(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY, appId.value)
}
override suspend fun getApplicationId(): ApplicationId? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY)
?.let(::ApplicationId)
}
override suspend fun sendPushToken(appId: ApplicationId, pushToken: String) {
withContext(dispatchers.io) {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
),
).getOrThrow()
}
}
override suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork> = withContext(dispatchers.io) {
tangemTechApi.getEligibleNetworksForPushNotifications().getOrThrow().mapNotNull {
NotificationsEligibleNetworkConverter.convert(it)
}
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.notifications.di
import com.tangem.data.notifications.DefaultNotificationsRepository
import com.tangem.data.notifications.DefaultPushNotificationsRepository
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -12,6 +14,10 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal interface NotificationsModule {
@Binds
@Singleton
fun bindPushNotificationsRepository(repository: DefaultPushNotificationsRepository): PushNotificationsRepository
@Binds
@Singleton
fun bindNotificationsRepository(repository: DefaultNotificationsRepository): NotificationsRepository

View file

@ -1,30 +1,21 @@
package com.tangem.data.notifications
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.utils.info.AppInfoProvider
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import androidx.datastore.preferences.core.Preferences
import com.squareup.moshi.Moshi
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.flowOf
class DefaultNotificationsRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val appInfoProvider: AppInfoProvider = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
@ -32,147 +23,77 @@ class DefaultNotificationsRepositoryTest {
preferencesDataStore = preferencesDataStore,
)
private val repository = DefaultNotificationsRepository(
tangemTechApi = tangemTechApi,
appInfoProvider = appInfoProvider,
appPreferencesStore = appPreferencesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN valid push token WHEN createApplicationId THEN returns application id`() = runTest {
fun `GIVEN shouldShowNotification returns true WHEN called THEN returns true`() = runTest {
// GIVEN
val pushToken = "test-push-token"
val expectedAppId = ApplicationId("test-app-id")
val expectedAppIdResponse = NotificationApplicationIdResponse(
appId = expectedAppId.value,
)
coEvery { appInfoProvider.platform } returns "android"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success(
expectedAppIdResponse,
)
val key = "test-key"
val preferences = mockk<Preferences>(relaxed = true)
every { preferences[PreferencesKeys.getShouldShowNotificationKey(key)] } returns true
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
val result = repository.createApplicationId(pushToken)
val result = repository.shouldShowNotification(key)
// THEN
assertThat(result).isEqualTo(expectedAppId)
coVerify {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = "android",
device = "test-device",
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
pushToken = pushToken,
),
)
}
assertThat(result).isTrue()
}
@Test
fun `GIVEN application id WHEN saveApplicationId THEN stores it in preferences`() = runTest {
fun `GIVEN shouldShowNotification returns false WHEN called THEN returns false`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val key = "test-key"
val preferences = mockk<Preferences>(relaxed = true)
coEvery { preferencesDataStore.updateData(any()) } returns preferences
every { preferences[PreferencesKeys.getShouldShowNotificationKey(key)] } returns false
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
repository.saveApplicationId(appId)
val result = repository.shouldShowNotification(key)
// THEN
assertThat(result).isFalse()
}
@Test
fun `GIVEN setShouldShowNotifications WHEN called THEN stores value in preferences`() = runTest {
// GIVEN
val key = "test-key"
val value = false
coEvery { preferencesDataStore.updateData(any()) } returns mockk(relaxed = true)
// WHEN
repository.setShouldShowNotifications(key, value)
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN stored application id WHEN getApplicationId THEN returns it`() = runTest {
fun `GIVEN incrementTronTokenFeeNotificationShowCounter WHEN called THEN increments counter`() = runTest {
// GIVEN
val expectedAppId = ApplicationId("test-app-id")
coEvery { preferencesDataStore.updateData(any()) } returns mockk(relaxed = true)
// WHEN
repository.incrementTronTokenFeeNotificationShowCounter()
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN getTronTokenFeeNotificationShowCounter WHEN called THEN returns counter value`() = runTest {
// GIVEN
val expectedCount = 5
val preferences = mockk<Preferences>(relaxed = true)
val key = stringPreferencesKey(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY.name)
every { preferences[key] } returns expectedAppId.value
every { preferences[PreferencesKeys.TRON_NETWORK_FEE_NOTIFICATION_SHOW_COUNT_KEY] } returns expectedCount
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
val result = repository.getApplicationId()
val result = repository.getTronTokenFeeNotificationShowCounter()
// THEN
assertThat(result).isEqualTo(expectedAppId)
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.sendPushToken(appId, pushToken)
// THEN
coVerify {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
}
}
@Test
fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest {
// GIVEN
val expectedNetworks = listOf(
CryptoNetworkResponse(
id = 1,
name = "Ethereum",
networkId = "ethereum",
),
CryptoNetworkResponse(
id = 2,
name = "Bitcoin",
networkId = "bitcoin",
),
)
coEvery { tangemTechApi.getEligibleNetworksForPushNotifications() } returns ApiResponse.Success(
expectedNetworks,
)
// WHEN
val result = repository.getEligibleNetworks()
// THEN
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[0]))
assertThat(result[1]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[1]))
coVerify { tangemTechApi.getEligibleNetworksForPushNotifications() }
assertThat(result).isEqualTo(expectedCount)
}
}

View file

@ -0,0 +1,178 @@
package com.tangem.data.notifications
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultPushNotificationsRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val appInfoProvider: AppInfoProvider = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val repository = DefaultPushNotificationsRepository(
tangemTechApi = tangemTechApi,
appInfoProvider = appInfoProvider,
appPreferencesStore = appPreferencesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN valid push token WHEN createApplicationId THEN returns application id`() = runTest {
// GIVEN
val pushToken = "test-push-token"
val expectedAppId = ApplicationId("test-app-id")
val expectedAppIdResponse = NotificationApplicationIdResponse(
appId = expectedAppId.value,
)
coEvery { appInfoProvider.platform } returns "android"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success(
expectedAppIdResponse,
)
// WHEN
val result = repository.createApplicationId(pushToken)
// THEN
assertThat(result).isEqualTo(expectedAppId)
coVerify {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = "android",
device = "test-device",
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
pushToken = pushToken,
),
)
}
}
@Test
fun `GIVEN application id WHEN saveApplicationId THEN stores it in preferences`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val preferences = mockk<Preferences>(relaxed = true)
coEvery { preferencesDataStore.updateData(any()) } returns preferences
// WHEN
repository.saveApplicationId(appId)
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN stored application id WHEN getApplicationId THEN returns it`() = runTest {
// GIVEN
val expectedAppId = ApplicationId("test-app-id")
val preferences = mockk<Preferences>(relaxed = true)
val key = stringPreferencesKey(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY.name)
every { preferences[key] } returns expectedAppId.value
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
val result = repository.getApplicationId()
// THEN
assertThat(result).isEqualTo(expectedAppId)
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.sendPushToken(appId, pushToken)
// THEN
coVerify {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
),
)
}
}
@Test
fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest {
// GIVEN
val expectedNetworks = listOf(
CryptoNetworkResponse(
id = 1,
name = "Ethereum",
networkId = "ethereum",
),
CryptoNetworkResponse(
id = 2,
name = "Bitcoin",
networkId = "bitcoin",
),
)
coEvery { tangemTechApi.getEligibleNetworksForPushNotifications() } returns ApiResponse.Success(
expectedNetworks,
)
// WHEN
val result = repository.getEligibleNetworks()
// THEN
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[0]))
assertThat(result[1]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[1]))
coVerify { tangemTechApi.getEligibleNetworksForPushNotifications() }
}
}

View file

@ -22,7 +22,6 @@ import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.extensions.canHandleBlockchain
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.HotCryptoCurrency
@ -170,23 +169,17 @@ internal class DefaultHotCryptoRepository(
// TODO: [REDACTED_JIRA]
private fun UserWallet.canHandleHotCrypto(hotToken: HotCryptoResponse.Token): Boolean {
if (this !is UserWallet.Cold) {
return true // TODO [REDACTED_TASK_KEY]
}
val isToken = hotToken.contractAddress != null && hotToken.decimalCount != null
val blockchain = hotToken.networkId?.let { Blockchain.fromNetworkId(it) } ?: return false
return if (isToken) {
scanResponse.card.canHandleToken(
canHandleToken(
blockchain = blockchain,
cardTypesResolver = cardTypesResolver,
excludedBlockchains = excludedBlockchains,
)
} else {
scanResponse.card.canHandleBlockchain(
canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = cardTypesResolver,
excludedBlockchains = excludedBlockchains,
)
}

View file

@ -4,7 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.onramp.models.response.Status
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
/**

View file

@ -10,8 +10,6 @@ import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toMigratedCoinId
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toCompressedPublicKey
import com.tangem.data.staking.converters.YieldConverter
@ -22,7 +20,6 @@ import com.tangem.data.staking.converters.transaction.StakingTransactionConverte
import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter
import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -32,22 +29,22 @@ import com.tangem.datasource.api.stakekit.models.response.model.action.StakingAc
import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.token.converter.StakingNetworkTypeConverter
import com.tangem.datasource.local.token.converter.TokenConverter
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingApproval
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.stakekit.NetworkType
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
@ -62,8 +59,6 @@ import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakingRepository(
@ -74,7 +69,6 @@ internal class DefaultStakingRepository(
private val walletManagersFacade: WalletManagersFacade,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
private val stakingIdFactory: StakingIdFactory,
moshi: Moshi,
) : StakingRepository {
@ -95,10 +89,6 @@ internal class DefaultStakingRepository(
private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) }
private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) }
override fun getSupportedIntegrationId(cryptoCurrencyId: CryptoCurrency.ID): String? {
return stakingIdFactory.createIntegrationId(currencyId = cryptoCurrencyId)
}
override suspend fun fetchEnabledYields() {
withContext(dispatchers.io) {
when (val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = false)) {
@ -202,7 +192,7 @@ internal class DefaultStakingRepository(
return@channelFlow
}
val isSupportedInMobileApp = getSupportedIntegrationId(cryptoCurrency.id).isNullOrEmpty().not()
val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null
getEnabledYields()
.distinctUntilChanged()
@ -248,7 +238,7 @@ internal class DefaultStakingRepository(
return StakingAvailability.Unavailable
}
val isSupportedInMobileApp = getSupportedIntegrationId(cryptoCurrency.id).isNullOrEmpty().not()
val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null
val yields = getEnabledYieldsSync()
if (yields.isEmpty()) {
@ -382,32 +372,6 @@ internal class DefaultStakingRepository(
}
}
override suspend fun getSingleYieldBalanceSync(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): YieldBalance {
val stakingId = stakingIdFactory.create(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
) ?: error("Could not create stakingId")
return stakingBalanceStoreV2.getSyncOrNull(userWalletId = userWalletId, stakingId = stakingId)
?: YieldBalance.Error(integrationId = stakingId.integrationId, address = stakingId.address)
}
override suspend fun getMultiYieldBalanceSync(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): List<YieldBalance>? {
val stakingIds = cryptoCurrencies.mapNotNull {
stakingIdFactory.create(userWalletId = userWalletId, currencyId = it.id, network = it.network)
}
return stakingBalanceStoreV2.getAllSyncOrNull(userWalletId)
?.filter { it.getStakingId() in stakingIds }
}
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
@ -422,14 +386,6 @@ internal class DefaultStakingRepository(
}
}
override fun getActionRequirementAmount(integrationId: String, stakingActionType: StakingActionType): BigDecimal? {
return when {
stakingIdFactory.isPolygonIntegrationId(integrationId) &&
stakingActionType == StakingActionType.CLAIM_REWARDS -> BigDecimal.ONE
else -> null
}
}
private suspend fun createActionRequestBody(
userWalletId: UserWalletId,
network: Network,
@ -443,7 +399,7 @@ internal class DefaultStakingRepository(
),
args = ActionRequestBodyArgs(
amount = params.amount.toPlainString(),
inputToken = TokenConverter.convertBack(params.token),
inputToken = YieldTokenConverter.convertBack(params.token),
validatorAddress = params.validatorAddress,
validatorAddresses = listOf(params.validatorAddress), // check on other networks
tronResource = getTronResource(network),
@ -481,17 +437,6 @@ internal class DefaultStakingRepository(
}
}
override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval {
val integrationId = stakingIdFactory.createIntegrationId(currencyId = cryptoCurrency.id)
return when (integrationId) {
Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId(),
Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId(),
-> StakingApproval.Needed(ETHEREUM_POLYGON_APPROVE_SPENDER)
else -> StakingApproval.Empty
}
}
private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data {
return when (Blockchain.fromId(networkId)) {
Blockchain.Solana,
@ -543,14 +488,7 @@ internal class DefaultStakingRepository(
}
}
@Suppress("unused")
companion object {
private const val YIELDS_STORE_KEY = "yields"
private const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908"
internal val YIELDS_WATITING_TIMEOUT = 15.seconds
private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79")
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.datasource.api.stakekit.models.response.model.AddressArgumentD
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO
import com.tangem.datasource.local.token.converter.TokenConverter
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
@ -24,8 +24,8 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
override fun convert(value: YieldDTO): Yield {
return Yield(
id = value.id.asMandatory("id"),
token = TokenConverter.convert(value.token.asMandatory("token")),
tokens = value.tokens.asMandatory("tokens").map(TokenConverter::convert),
token = YieldTokenConverter.convert(value.token.asMandatory("token")),
tokens = value.tokens.asMandatory("tokens").map(YieldTokenConverter::convert),
args = convertArgs(value.args.asMandatory("args")),
status = convertStatus(value.status.asMandatory("status")),
apy = value.apy.asMandatory("apy"),
@ -91,9 +91,9 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
logoUri = metadataDTO.logoUri.asMandatory("logoUri"),
description = metadataDTO.description.asMandatory("description"),
documentation = metadataDTO.documentation,
gasFeeToken = TokenConverter.convert(metadataDTO.gasFeeTokenDTO.asMandatory("gasFeeTokenDTO")),
token = TokenConverter.convert(metadataDTO.tokenDTO.asMandatory("tokenDTO")),
tokens = metadataDTO.tokensDTO.asMandatory("tokensDTO").map(TokenConverter::convert),
gasFeeToken = YieldTokenConverter.convert(metadataDTO.gasFeeTokenDTO.asMandatory("gasFeeTokenDTO")),
token = YieldTokenConverter.convert(metadataDTO.tokenDTO.asMandatory("tokenDTO")),
tokens = metadataDTO.tokensDTO.asMandatory("tokensDTO").map(YieldTokenConverter::convert),
type = metadataDTO.type.asMandatory("type"),
rewardSchedule = convertRewardSchedule(metadataDTO.rewardSchedule.asMandatory("rewardSchedule")),
cooldownPeriod = metadataDTO.cooldownPeriod?.let { convertPeriod(it) },

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.converters.transaction
import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingGasEstimateDTO
import com.tangem.datasource.local.token.converter.TokenConverter
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
import com.tangem.utils.converter.Converter
@ -10,7 +10,7 @@ internal object GasEstimateConverter : Converter<StakingGasEstimateDTO, StakingG
override fun convert(value: StakingGasEstimateDTO): StakingGasEstimate {
return StakingGasEstimate(
amount = value.amount,
token = TokenConverter.convert(value.token),
token = YieldTokenConverter.convert(value.token),
gasLimit = value.gasLimit,
)
}

View file

@ -10,7 +10,6 @@ import com.tangem.data.staking.DefaultStakingTransactionHashRepository
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.di.NetworkMoshi
@ -45,7 +44,6 @@ internal object StakingDataModule {
walletManagersFacade: WalletManagersFacade,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
stakingIdFactory: StakingIdFactory,
@NetworkMoshi moshi: Moshi,
): StakingRepository {
return DefaultStakingRepository(
@ -57,7 +55,6 @@ internal object StakingDataModule {
getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles,
moshi = moshi,
stakingIdFactory = stakingIdFactory,
)
}

View file

@ -42,9 +42,8 @@ internal object YieldBalanceSupplierModule {
listOf(
"single_yield_balance",
params.userWalletId.stringValue,
params.currencyId.value,
params.network.id.rawId,
params.network.id.derivationPath,
params.stakingId.integrationId,
params.stakingId.address,
)
.joinToString(separator = "_")
},

View file

@ -1,15 +1,11 @@
package com.tangem.data.staking.multi
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.right
import arrow.core.toOption
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
@ -18,10 +14,10 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
@ -36,7 +32,6 @@ import javax.inject.Inject
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating StakingID
* @property stakeKitApi stake kit API
* @property dispatchers dispatchers
*
@ -46,7 +41,6 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiYieldBalanceFetcher {
@ -54,11 +48,12 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
override suspend fun invoke(params: MultiYieldBalanceFetcher.Params): Either<Throwable, Unit> {
Timber.i("Start fetching yield balances for params:\n$params")
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
val stakingIds = params.stakingIds.ifEmpty {
Timber.i("Nothing to fetch, empty stakingIds for ${params.userWalletId}")
return Unit.right()
}
val stakingIds = getStakingIds(params).getOrElse {
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
@ -94,30 +89,6 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
}
}
private suspend fun getStakingIds(params: MultiYieldBalanceFetcher.Params) = either {
val stakingIds = catch(
block = {
params.currencyIdWithNetworkMap.mapNotNullTo(hashSetOf()) { (currencyId, network) ->
stakingIdFactory.create(
userWalletId = params.userWalletId,
currencyId = currencyId,
network = network,
)
}
},
catch = ::raise,
)
ensure(stakingIds.isNotEmpty()) {
val exception = IllegalStateException("Unable to create staking ids for $params: list is empty")
Timber.e(exception)
raise(exception)
}
stakingIds
}
private suspend fun getAvailableStakingIds(userWalletId: UserWalletId, stakingIds: Set<StakingID>): Set<StakingID> {
val yieldIds = getYieldsIds(userWalletId = userWalletId)
@ -178,7 +149,9 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
// TODO: in the future, consider optimizing this part
.chunked(size = 15) // StakeKitApi limitation: no more than 15 requests at the same time
.map {
async(dispatchers.io) { stakeKitApi.getMultipleYieldBalances(it).bind() }
async(dispatchers.io) {
stakeKitApi.getMultipleYieldBalances(it).bind()
}
}
.awaitAll()
.flatten()

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.multi
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted

View file

@ -20,9 +20,7 @@ internal class DefaultSingleYieldBalanceFetcher @Inject constructor(
return multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(
userWalletId = params.userWalletId,
currencyIdWithNetworkMap = mapOf(
params.currencyId to params.network,
),
stakingIds = setOf(params.stakingId),
),
)
}

View file

@ -2,9 +2,7 @@ package com.tangem.data.staking.single
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
@ -24,7 +22,7 @@ import timber.log.Timber
*
* @property params params
* @property multiYieldBalanceSupplier multi yield balance supplier
* @property stakingIdFactory factory for creating [StakingID]
* @property analyticsExceptionHandler analytics exception handler
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
@ -32,20 +30,14 @@ import timber.log.Timber
internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
@Assisted private val params: SingleYieldBalanceProducer.Params,
private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier,
private val stakingIdFactory: StakingIdFactory,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleYieldBalanceProducer {
override val fallback: YieldBalance by lazy {
YieldBalance.Error(
integrationId = stakingIdFactory.createIntegrationId(currencyId = params.currencyId),
address = null,
)
YieldBalance.Error(stakingId = params.stakingId)
}
private var stakingId: StakingID? = null
override fun produce(): Flow<YieldBalance> {
Timber.i("Producing yield balance for params:\n$params")
@ -53,14 +45,9 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId),
)
.mapNotNull { balances ->
val currentStakingId = getStakingId()
val currentStakingId = params.stakingId
if (currentStakingId == null) {
Timber.i("Staking ID is null for params: $params")
return@mapNotNull YieldBalance.Unsupported
}
val currentBalances = balances.filter { it.getStakingId() == currentStakingId }
val currentBalances = balances.filter { it.stakingId == currentStakingId }
if (currentBalances.size > 1) {
analyticsExceptionHandler.sendException(
@ -86,34 +73,16 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
currentBalances.first()
}
} else {
val balance = currentBalances.firstOrNull()
val balance = currentBalances.firstOrNull() ?: return@mapNotNull null
if (balance != null) {
Timber.i("Yield balance found for $currentStakingId:\n$balance")
balance
} else {
Timber.i("No yield balance found for $currentStakingId:\n${YieldBalance.Unsupported}")
YieldBalance.Unsupported
}
Timber.i("Yield balance found for $currentStakingId:\n$balance")
balance
}
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
private suspend fun getStakingId(): StakingID? {
val saved = stakingId
if (saved != null) return saved
return stakingIdFactory.create(
userWalletId = params.userWalletId,
currencyId = params.currencyId,
network = params.network,
)
.also { stakingId = it }
}
@AssistedFactory
interface Factory : SingleYieldBalanceProducer.Factory {
override fun create(params: SingleYieldBalanceProducer.Params): DefaultSingleYieldBalanceProducer

View file

@ -5,9 +5,9 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrap
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
@ -46,6 +46,8 @@ internal class DefaultYieldsBalancesStore(
value = cachedStatuses.map { (stringWalletId, wrappers) ->
val key = UserWalletId(stringWalletId)
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
.filterNotNull()
.toSet()
key to value
}
@ -61,7 +63,7 @@ internal class DefaultYieldsBalancesStore(
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? {
return runtimeStore.getSyncOrNull()
?.get(userWalletId)
?.firstOrNull { stakingId == it.getStakingId() }
?.firstOrNull { it.stakingId == stakingId }
}
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
@ -96,11 +98,13 @@ internal class DefaultYieldsBalancesStore(
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
.filterNotNull()
.toSet()
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new -> old.getStakingId() == new.getStakingId() }
?.addOrReplace(newBalances) { old, new -> old.stakingId == new.stakingId }
?: newBalances
}
}
@ -135,7 +139,7 @@ internal class DefaultYieldsBalancesStore(
val balances = stakingIds.mapNotNullTo(hashSetOf()) { stakingId ->
val balance = portfolioBalances
.firstOrNull { stakingId == it.getStakingId() }
.firstOrNull { it.stakingId == stakingId }
?: ifNotFound(stakingId)
?: return@mapNotNullTo null
@ -143,7 +147,7 @@ internal class DefaultYieldsBalancesStore(
}
val updatedBalances = portfolioBalances.addOrReplace(items = balances) { old, new ->
old.getStakingId() == new.getStakingId()
old.stakingId == new.stakingId
}
put(key = userWalletId, value = updatedBalances)
@ -151,9 +155,7 @@ internal class DefaultYieldsBalancesStore(
}
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance {
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance = YieldBalance.Error(stakingId = id)
private fun YieldBalanceWrapperDTO.getStakingId(): StakingID? {
val integrationId = integrationId

View file

@ -1,9 +1,9 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import kotlinx.coroutines.flow.Flow
/**

View file

@ -1,77 +0,0 @@
package com.tangem.data.staking.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toMigratedCoinId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.walletmanager.WalletManagersFacade
import javax.inject.Inject
/**
* Factory of [StakingID]
*
* @property walletManagersFacade wallet manager facade
*
[REDACTED_AUTHOR]
*/
internal class StakingIdFactory @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): StakingID? {
val address = walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
val integrationId = createIntegrationId(currencyId)
if (address == null || integrationId == null) return null
return StakingID(integrationId = integrationId, address = address)
}
fun createIntegrationId(currencyId: CryptoCurrency.ID): String? {
val integrationKey = with(currencyId) { rawNetworkId.plus(rawCurrencyId) }
return integrationIdMap[integrationKey]
}
fun isPolygonIntegrationId(integrationId: String): Boolean = integrationId == ETHEREUM_POLYGON_INTEGRATION_ID
@Suppress("UnusedPrivateMember", "unused")
companion object {
private const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking"
private const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking"
private const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking"
private const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking"
private const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking"
private const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking"
private const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking"
private const val TRON_INTEGRATION_ID = "tron-trx-native-staking"
private const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking"
private const val KAVA_INTEGRATION_ID = "kava-kava-native-staking"
private const val NEAR_INTEGRATION_ID = "near-near-native-staking"
private const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking"
private const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking"
// uncomment items as implementation is ready
private val integrationIdMap = mapOf(
Blockchain.TON.toDefaultKey() to TON_INTEGRATION_ID,
Blockchain.Solana.toDefaultKey() to SOLANA_INTEGRATION_ID,
Blockchain.Cosmos.toDefaultKey() to COSMOS_INTEGRATION_ID,
Blockchain.Tron.toDefaultKey() to TRON_INTEGRATION_ID,
Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
// Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
Blockchain.BSC.toDefaultKey() to BINANCE_INTEGRATION_ID,
// Blockchain.Polkadot.toDefaultKey() to POLKADOT_INTEGRATION_ID,
// Blockchain.Avalanche.toDefaultKey() to AVALANCHE_INTEGRATION_ID,
// Blockchain.Cronos.toDefaultKey() to CRONOS_INTEGRATION_ID,
// Blockchain.Kava.toDefaultKey() to KAVA_INTEGRATION_ID,
// Blockchain.Near.toDefaultKey() to NEAR_INTEGRATION_ID,
// Blockchain.Tezos.toDefaultKey() to TEZOS_INTEGRATION_ID,
Blockchain.Cardano.toDefaultKey() to CARDANO_INTEGRATION_ID,
)
private fun Blockchain.toDefaultKey(): String = id + toCoinId()
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.utils
import com.tangem.datasource.api.stakekit.models.request.Address
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.models.staking.StakingID
/**
* Factory for creating [Address]

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.utils
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.models.staking.StakingID
/**
* Factory for creating [YieldBalanceRequestBody]

View file

@ -3,8 +3,8 @@ package com.tangem.data.staking
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.models.staking.YieldBalance
internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): YieldBalance {
return YieldBalanceConverter(source = source).convert(this)
return YieldBalanceConverter(source = source).convert(this)!!
}

View file

@ -1,14 +1,12 @@
package com.tangem.data.staking.multi
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
@ -16,8 +14,8 @@ import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -34,55 +32,46 @@ internal class DefaultMultiYieldBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk(relaxUnitFun = true)
private val stakeKitApi: StakeKitApi = mockk()
private val fetcher = DefaultMultiYieldBalanceFetcher(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, stakingYieldsStore, yieldsBalancesStore, stakingIdFactory, stakeKitApi)
clearMocks(userWalletsStore, stakingYieldsStore, yieldsBalancesStore, stakeKitApi)
}
@Test
fun `fetch yields balances successfully`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = tonAndSolanaIds.map(YieldBalanceRequestBodyFactory::create).sortedBy { it.integrationId }
val requests = tonAndSolanaIds.map(YieldBalanceRequestBodyFactory::create)
val result = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId),
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId),
)
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result)
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
@ -91,40 +80,30 @@ internal class DefaultMultiYieldBalanceFetcherTest {
coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) }
Truth.assertThat(actual.isRight()).isTrue()
assertEitherRight(actual)
}
@Test
fun `fetch yields balances successfully if one of stakingIds is unavailable`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) } just Runs
val requests = listOf(YieldBalanceRequestBodyFactory.create(tonId))
val result = setOf(MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId))
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result)
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
@ -132,15 +111,13 @@ internal class DefaultMultiYieldBalanceFetcherTest {
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
Truth.assertThat(actual.isRight()).isTrue()
assertEitherRight(actual)
}
@Test
fun `fetch yields balances failure if user wallet is not supported`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -149,10 +126,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val actual = fetcher.invoke(params)
// Assert
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
@ -162,17 +138,13 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if userWalletsStore returns null`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null
@ -180,10 +152,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val actual = fetcher.invoke(params)
// Assert
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
@ -193,69 +164,23 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingIdFactory returns null`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns null
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns null
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
}
coVerify(inverse = true) {
yieldsBalancesStore.refresh(any(), any<Set<StakingID>>())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(any(), any())
yieldsBalancesStore.storeError(any(), any())
}
val expected = IllegalStateException("Unable to create staking ids for $params: list is empty")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
@ -268,33 +193,23 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
@ -307,38 +222,28 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if yields converting is failed`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(
MockYieldDTOFactory.create(tonId).copy(id = null),
MockYieldDTOFactory.create(solanaId).copy(id = null),
)
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
@ -351,35 +256,25 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1")))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
@ -394,47 +289,37 @@ internal class DefaultMultiYieldBalanceFetcherTest {
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(solanaId, tonId).joinToString()}
stakingIds: ${tonAndSolanaIds.joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
@Test
fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = setOf(solanaId, tonId).map(YieldBalanceRequestBodyFactory::create)
val requests = setOf(tonId, solanaId).map(YieldBalanceRequestBodyFactory::create)
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException)
as ApiResponse<Set<YieldBalanceWrapperDTO>>
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns errorResponse
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
@ -445,20 +330,13 @@ internal class DefaultMultiYieldBalanceFetcherTest {
val expected = ApiResponseError.NetworkException
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
assertEitherLeft(actual, expected)
}
private companion object {
val userWalletId = UserWalletId("011")
val userWallet = MockUserWalletFactory.create()
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val solana = mocks.createCoin(Blockchain.Solana)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",

View file

@ -5,9 +5,9 @@ import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every

View file

@ -3,8 +3,7 @@ package com.tangem.data.staking.single
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
@ -37,15 +36,11 @@ internal class DefaultSingleYieldBalanceFetcherTest {
@Test
fun `fetch yield balance successfully`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
val params = SingleYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val multiParams = MultiYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyIdWithNetworkMap = mapOf(ton.id to ton.network),
stakingIds = setOf(tonId),
)
val multiResult = Unit.right()
@ -64,16 +59,9 @@ internal class DefaultSingleYieldBalanceFetcherTest {
@Test
fun `fetch yield balance failure`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
val params = SingleYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val multiParams = MultiYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyIdWithNetworkMap = mapOf(ton.id to ton.network),
)
val multiParams = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = setOf(tonId))
val multiResult = IllegalStateException().left()
@ -89,6 +77,6 @@ internal class DefaultSingleYieldBalanceFetcherTest {
private companion object {
val userWalletId = UserWalletId("011")
val ton = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
}
}

View file

@ -1,54 +1,60 @@
package com.tangem.data.staking.single
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.staking.toDomain
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleYieldBalanceProducerTest {
private val params = SingleYieldBalanceProducer.Params(
userWalletId = UserWalletId(stringValue = "011"),
currencyId = ton.id,
network = ton.network,
stakingId = tonId,
)
private val multiNetworkStatusSupplier = mockk<MultiYieldBalanceSupplier>()
private val stakingIdFactory = mockk<StakingIdFactory>()
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>(relaxUnitFun = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultSingleYieldBalanceProducer(
params = params,
stakingIdFactory = stakingIdFactory,
multiYieldBalanceSupplier = multiNetworkStatusSupplier,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = dispatchers,
)
@BeforeEach
fun resetMocks() {
clearMocks(multiNetworkStatusSupplier, analyticsExceptionHandler)
}
@Test
fun `test that flow is mapped for data from params`() = runTest {
fun `flow is mapped for data from params`() = runTest {
// Arrange
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val expected = flowOf(
val multiFlow = flowOf(
setOf(
balance,
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
@ -56,97 +62,89 @@ internal class DefaultSingleYieldBalanceProducerTest {
)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val actual = producer.produce()
// Act
val actual = getEmittedValues(flow = producer.produce())
verify { multiNetworkStatusSupplier(multiParams) }
Truth.assertThat(actual).hasSize(1)
Truth.assertThat(actual).containsExactly(balance)
val values = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(balance))
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Test
fun `test that flow is updated if yield balance is updated`() = runTest {
val expected = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
fun `flow is updated if yield balance is updated`() = runTest {
// Arrange
val multiFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val actual = producer.produceWithFallback()
val producerFlow = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
expected.emit(value = setOf(balance))
val updatedBalance = YieldBalance.Error(stakingId = tonId)
val values1 = getEmittedValues(flow = actual)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(balance))
// Act (second emit)
multiFlow.emit(value = setOf(updatedBalance))
val actual2 = getEmittedValues(flow = producerFlow)
// second emit
val updatedStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = tonId.address)
expected.emit(value = setOf(updatedStatus))
// Assert (second emit)
Truth.assertThat(actual2).hasSize(2)
Truth.assertThat(actual2).containsExactly(balance, updatedBalance)
val values2 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(balance, updatedStatus))
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val expected = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
fun `flow is filtered the same status`() = runTest {
// Arrange
val multiFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val actual = producer.produceWithFallback()
val producerFlow = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
expected.emit(value = setOf(balance))
val values1 = getEmittedValues(flow = actual)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(balance))
// Act (second emit)
multiFlow.emit(value = setOf(balance))
val actual2 = getEmittedValues(flow = producerFlow)
// second emit
expected.emit(value = setOf(balance))
// Assert (second emit)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
val values2 = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balance))
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Test
fun `test if flow throws exception`() = runTest {
fun `flow throws exception`() = runTest {
// Arrange
val exception = IllegalStateException()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val innerFlow = MutableStateFlow(value = false)
val expected = flow {
val multiFlow = flow {
if (innerFlow.value) {
emit(setOf(balance))
} else {
@ -156,83 +154,52 @@ internal class DefaultSingleYieldBalanceProducerTest {
.buffer(capacity = 5)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
every { stakingIdFactory.createIntegrationId(currencyId = params.currencyId) } returns tonId.integrationId
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val actual = producer.produceWithFallback()
val producerFlow = producer.produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// Act (first emit)
val actual1 = getEmittedValues(flow = producerFlow)
val values1 = getEmittedValues(flow = actual)
// Assert (first emit)
val fallbackStatus = YieldBalance.Error(stakingId = tonId.copy(address = "0x1"))
coVerify(inverse = true) { stakingIdFactory.create(any(), any(), any()) }
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = null)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(fallbackStatus)
// Act (second emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balance))
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Test
fun `test if flow doesn't contain network from params`() = runTest {
fun `flow doesn't contain network from params`() = runTest {
// Arrange
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain()
val yieldBalancesFlow = flowOf(setOf(balance))
val multiFlow = flowOf(setOf(balance))
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns yieldBalancesFlow
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val actual = producer.produce()
val producerFlow = producer.produce()
verify { multiNetworkStatusSupplier(multiParams) }
// Act
val actual = getEmittedValues(flow = producerFlow)
val values = getEmittedValues(flow = actual)
// Assert
Truth.assertThat(actual).isEmpty()
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
val expected = YieldBalance.Unsupported
Truth.assertThat(values.first()).isEqualTo(expected)
}
@Test
fun `test if wallet manager facade returns null`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val yieldBalancesFlow = flowOf(setOf(balance))
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns yieldBalancesFlow
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns null
val actual = producer.produce()
verify { multiNetworkStatusSupplier(multiParams) }
val values = getEmittedValues(flow = actual)
coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) }
val expected = YieldBalance.Unsupported
Truth.assertThat(values.first()).isEqualTo(expected)
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
private companion object {
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",

View file

@ -6,8 +6,8 @@ import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test

View file

@ -6,8 +6,8 @@ import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk

View file

@ -7,9 +7,9 @@ import com.tangem.data.staking.toDomain
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -129,12 +129,7 @@ internal class YieldsBalancesStoreUpdateMethodsTest {
store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId))
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
userWalletId to setOf(YieldBalance.Error(stakingId)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)

View file

@ -1,188 +0,0 @@
package com.tangem.data.staking.utils
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class StakingIdFactoryTest {
private val walletManagersFacade: WalletManagersFacade = mockk()
private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade)
@BeforeEach
fun resetMocks() {
clearMocks(walletManagersFacade)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CreateIntegrationId {
@ParameterizedTest
@ProvideTestModels
fun createIntegrationId(model: CreateIntegrationIdModel) {
// Act
val actual = factory.createIntegrationId(currencyId = model.currencyId)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.TON),
expected = "ton-ton-chorus-one-pools-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.Solana),
expected = "solana-sol-native-multivalidator-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.Cosmos),
expected = "cosmos-atom-native-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.Tron),
expected = "tron-trx-native-staking",
),
CreateIntegrationIdModel(
currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"),
expected = "ethereum-matic-native-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.BSC),
expected = "bsc-bnb-native-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.Cardano),
expected = "cardano-ada-native-staking",
),
CreateIntegrationIdModel(
currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin),
expected = null,
),
)
}
data class CreateIntegrationIdModel(val currencyId: CryptoCurrency.ID, val expected: String?)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Create {
private val defaultAddress = "address"
@Test
fun `create returns null if address is null`() = runTest {
// Arrange
val userWalletId = UserWalletId(stringValue = "011")
val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
coEvery {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network)
} returns null
// Act
val actual = factory.create(
userWalletId = userWalletId,
currencyId = currency.id,
network = currency.network,
)
// Assert
val expected = null
Truth.assertThat(actual).isEqualTo(expected)
coVerify(exactly = 1) {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network)
}
}
@ParameterizedTest
@ProvideTestModels
fun create(model: CreateModel) = runTest {
// Arrange
val userWalletId = UserWalletId(stringValue = "011")
val network = MockCryptoCurrencyFactory().createCoin(Blockchain.TON).network
coEvery {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
} returns defaultAddress
// Act
val actual = factory.create(userWalletId = userWalletId, currencyId = model.currencyId, network = network)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
coVerify(exactly = 1) {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
}
}
private fun provideTestModels() = listOf(
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.TON),
expected = createStakingId(integrationId = "ton-ton-chorus-one-pools-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.Solana),
expected = createStakingId(integrationId = "solana-sol-native-multivalidator-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.Cosmos),
expected = createStakingId(integrationId = "cosmos-atom-native-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.Tron),
expected = createStakingId(integrationId = "tron-trx-native-staking"),
),
CreateModel(
currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"),
expected = createStakingId(integrationId = "ethereum-matic-native-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.BSC),
expected = createStakingId(integrationId = "bsc-bnb-native-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.Cardano),
expected = createStakingId(integrationId = "cardano-ada-native-staking"),
),
CreateModel(
currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin),
expected = null,
),
)
private fun createStakingId(integrationId: String): StakingID {
return StakingID(integrationId = integrationId, address = defaultAddress)
}
}
data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingID?)
private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID {
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}${blockchain.toCoinId()}")
}
}

View file

@ -22,6 +22,7 @@ import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
@ -29,7 +30,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope

View file

@ -13,7 +13,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectList
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -24,9 +24,8 @@ import com.tangem.domain.swap.models.SwapTransactionModel
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
internal class DefaultSwapTransactionRepository(
private val appPreferencesStore: AppPreferencesStore,
@ -95,36 +94,34 @@ internal class DefaultSwapTransactionRepository(
}
}
override suspend fun getTransactions(
override fun getTransactions(
userWallet: UserWallet,
cryptoCurrencyId: CryptoCurrency.ID,
): Flow<List<SwapTransactionListModel>?> {
return withContext(dispatchers.io) {
val txStatuses = appPreferencesStore.getObjectMapSync<SwapStatusDTO>(
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
)
appPreferencesStore.getObjectList<SwapTransactionListDTO>(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
).map { savedTransactions ->
val currencyTxs = savedTransactions
?.filter {
it.userWalletId == userWallet.walletId.stringValue &&
(
it.toCryptoCurrencyId == cryptoCurrencyId.value ||
it.fromCryptoCurrencyId == cryptoCurrencyId.value
)
}
): Flow<List<SwapTransactionListModel>?> = combine(
flow = appPreferencesStore.getObjectList<SwapTransactionListDTO>(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
),
flow2 = appPreferencesStore.getObjectMap<SwapStatusDTO>(
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
),
) { savedTransactions, txStatuses ->
val currencyTxs = savedTransactions
?.filter {
it.userWalletId == userWallet.walletId.stringValue &&
(
it.toCryptoCurrencyId == cryptoCurrencyId.value ||
it.fromCryptoCurrencyId == cryptoCurrencyId.value
)
}
currencyTxs?.mapNotNull {
listConverter.convertBack(
value = it,
userWallet = userWallet,
txStatuses = txStatuses,
)
}
}.flowOn(dispatchers.io)
currencyTxs?.mapNotNull {
listConverter.convertBack(
value = it,
userWallet = userWallet,
txStatuses = txStatuses,
)
}
}
}.flowOn(dispatchers.default)
override suspend fun removeTransaction(
userWalletId: UserWalletId,
@ -188,7 +185,7 @@ internal class DefaultSwapTransactionRepository(
)
val updatesMap = savedMap.toMutableMap()
updatesMap[txId] = savedStatusConverter.convertBack(
updatesMap[txId] = savedStatusConverter.convert(
status.copy(
refundTokensResponse = refundTokenCurrency?.let {
userTokensResponseFactory.createResponseToken(refundTokenCurrency)

View file

@ -6,9 +6,9 @@ import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapStatusModel
import com.tangem.utils.converter.TwoWayConverter
internal class SavedSwapStatusConverter : TwoWayConverter<SwapStatusDTO, SwapStatusModel> {
internal class SavedSwapStatusConverter : TwoWayConverter<SwapStatusModel, SwapStatusDTO> {
override fun convert(value: SwapStatusDTO) = SwapStatusModel(
override fun convertBack(value: SwapStatusDTO) = SwapStatusModel(
providerId = value.providerId,
status = SwapStatus.entries.firstOrNull {
it.name.lowercase() == value.status?.name?.lowercase()
@ -22,7 +22,7 @@ internal class SavedSwapStatusConverter : TwoWayConverter<SwapStatusDTO, SwapSta
averageDuration = value.averageDuration,
)
override fun convertBack(value: SwapStatusModel) = SwapStatusDTO(
override fun convert(value: SwapStatusModel) = SwapStatusDTO(
providerId = value.providerId,
status = SavedSwapStatus.entries.firstOrNull {
it.name.lowercase() == value.status?.name?.lowercase()

View file

@ -3,8 +3,10 @@ package com.tangem.data.swap.converter.transaction
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.swap.models.SwapStatusDTO
import com.tangem.data.swap.models.SwapTransactionDTO
import com.tangem.data.swap.models.SwapTxTypeDTO
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapTransactionModel
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.utils.converter.TwoWayConverter
internal class SavedSwapTransactionConverter(
@ -21,7 +23,10 @@ internal class SavedSwapTransactionConverter(
fromCryptoAmount = value.fromCryptoAmount,
toCryptoAmount = value.toCryptoAmount,
provider = value.provider,
status = value.status,
status = value.status?.let(statusConverter::convert),
swapTxType = SwapTxTypeDTO.entries.firstOrNull {
it.name.lowercase() == value.swapTxType?.name?.lowercase()
},
)
override fun convertBack(value: SwapTransactionDTO) = SwapTransactionModel(
@ -30,7 +35,10 @@ internal class SavedSwapTransactionConverter(
fromCryptoAmount = value.fromCryptoAmount,
toCryptoAmount = value.toCryptoAmount,
provider = value.provider,
status = value.status,
status = value.status?.let(statusConverter::convertBack),
swapTxType = SwapTxType.entries.firstOrNull {
it.name.lowercase() == value.swapTxType?.name?.lowercase()
},
)
fun convertBack(
@ -53,7 +61,10 @@ internal class SavedSwapTransactionConverter(
fromCryptoAmount = value.fromCryptoAmount,
toCryptoAmount = value.toCryptoAmount,
provider = value.provider,
status = statusWithRefundCurrency?.let(statusConverter::convert),
status = statusWithRefundCurrency?.let(statusConverter::convertBack),
swapTxType = SwapTxType.entries.firstOrNull {
it.name.lowercase() == value.swapTxType?.name?.lowercase()
},
)
}
}

View file

@ -14,7 +14,6 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
@ -49,7 +48,6 @@ internal object SwapDataModule {
dataSignatureVerifier: DataSignatureVerifier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
stakingRepository: StakingRepository,
@NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 {
return DefaultSwapRepositoryV2(
@ -61,7 +59,7 @@ internal object SwapDataModule {
moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
currencyStatusProxyCreator = CurrencyStatusProxyCreator(stakingRepository),
currencyStatusProxyCreator = CurrencyStatusProxyCreator(),
)
}

View file

@ -4,7 +4,6 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.swap.models.SwapStatusModel
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
@ -36,5 +35,7 @@ internal data class SwapTransactionDTO(
@Json(name = "provider")
val provider: ExpressProvider,
@Json(name = "status")
val status: SwapStatusModel? = null,
val status: SwapStatusDTO? = null,
@Json(name = "swapTxType")
val swapTxType: SwapTxTypeDTO? = SwapTxTypeDTO.Swap,
)

View file

@ -0,0 +1,13 @@
package com.tangem.data.swap.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
internal enum class SwapTxTypeDTO {
@Json(name = "Swap")
Swap,
@Json(name = "SendWithSwap")
SendWithSwap,
}

View file

@ -18,7 +18,6 @@ import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -68,7 +67,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher(
}
},
onError = {
handleFetchTokensError(error = it, userWallet = userWallet.requireColdWallet()) // TODO 11142
handleFetchTokensError(error = it, userWallet = userWallet)
},
)
}

View file

@ -4,7 +4,7 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
@ -35,7 +35,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr
get() = emptySet()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId).requireColdWallet() // TODO [REDACTED_TASK_KEY]
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")

View file

@ -22,9 +22,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade

View file

@ -7,11 +7,11 @@ import com.tangem.blockchain.common.ReserveAmountProvider
import com.tangem.blockchain.common.UtxoAmountLimitProvider
import com.tangem.data.tokens.converters.UtxoConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.utils.getTotalStakingBalance
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.CurrencyAmount
import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning

View file

@ -22,6 +22,7 @@ dependencies {
/** Project - Domain */
implementation(projects.domain.visa)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)

View file

@ -3,12 +3,11 @@ package com.tangem.data.visa.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.domain.card.common.util.derivationStyleProvider
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.lib.visa.model.VisaContractInfo
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
@ -34,7 +33,7 @@ internal class VisaCurrencyFactory @Inject constructor(
val currencyNetwork = networkFactory.create(
blockchain = Blockchain.Polygon,
extraDerivationPath = null,
derivationStyleProvider = userWallet.requireColdWallet().scanResponse.derivationStyleProvider,
derivationStyleProvider = userWallet.derivationStyleProvider,
canHandleTokens = true,
) ?: error("Unable to create network for Visa currency")

View file

@ -7,10 +7,10 @@ import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.walletmanager.extensions.makePublicKey
import com.tangem.data.walletmanager.extensions.makeWalletManagerForApp
import com.tangem.domain.card.DerivationStyleProvider
import com.tangem.domain.card.common.util.derivationStyleProvider
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import timber.log.Timber
internal class WalletManagerFactory(

View file

@ -11,9 +11,14 @@ android {
}
dependencies {
implementation(projects.data.common)
/** Tangem libraries */
implementation(tangemDeps.blockchain) // android-library
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
implementation(projects.libs.tangemSdkApi)
implementation(projects.libs.blockchainSdk)
/** Core */
implementation(projects.core.datasource)
@ -21,6 +26,7 @@ dependencies {
/** Domain */
implementation(projects.domain.wallets)
implementation(projects.domain.card)
api(projects.domain.models)
/** Domain models */
@ -29,15 +35,17 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
implementation(project(":domain:legacy"))
kapt(deps.hilt.kapt)
/** Other deps */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.timber)
/** tests */
testImplementation(projects.domain.models)
testImplementation(projects.common.test)
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)

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,144 @@
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.card.configs.CardConfig
import com.tangem.domain.card.configs.Wallet2CardConfig
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.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> {
val config = when (userWallet) {
is UserWallet.Cold -> CardConfig.createConfig(userWallet.scanResponse.card)
is UserWallet.Hot -> Wallet2CardConfig // TODO [REDACTED_TASK_KEY] [Hot Wallet] Derivation config for hot wallet
}
return mapNotNull { network ->
val blockchain = network.toBlockchain()
val curve = config.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,21 @@ 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.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.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 +51,21 @@ 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
}

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,118 @@
package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
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 hotWalletPasswordRequester: HotWalletPasswordRequester,
) {
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 auth = when (hotWalletId.authType) {
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth
HotWalletId.AuthType.Password -> requestPassword(false)
HotWalletId.AuthType.Biometry -> 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(
originalAuth = auth,
auth = auth,
block = { blockAuth ->
block(blockAuth).also {
// TODO [REDACTED_TASK_KEY] [Hot Wallet] Authorization by access code
// if user has biometry enabled, we set it as the new auth method
if (blockAuth is HotAuth.Password /*&& has biometry enabled */) {
tangemHotSdk.changeAuth(
unlockHotWallet = UnlockHotWallet(
walletId = hotWalletId,
auth = blockAuth,
),
auth = HotAuth.Biometry,
)
}
}
},
)
}
private suspend fun <T> runCatchingWrongPassInternal(
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(true)
return@getOrElse runCatchingWrongPassInternal(
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(originalAuth is HotAuth.Biometry)
runCatchingWrongPassInternal(
originalAuth = originalAuth,
auth = passResult,
block = block,
)
}
private suspend fun requestPassword(hasBiometry: Boolean): HotAuth {
return hotWalletPasswordRequester.requestPassword(hasBiometry).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

@ -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()),
)
}
}