Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 15:31:57 +03:00
commit 47dc1c38df
762 changed files with 23960 additions and 10924 deletions

View file

@ -26,14 +26,13 @@ import com.tangem.datasource.utils.setTypes
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.repository.AccountsExpandedRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -69,7 +68,7 @@ internal object AccountDataModule {
fun provideAccountsExpandedRepository(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): AccountsExpandedRepository {
val store = DataStoreFactory.create<Map<String, Set<AccountsExpandedDTO>>>(
serializer = MoshiDataStoreSerializer(
@ -78,7 +77,7 @@ internal object AccountDataModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "account_expanded_store") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
return DefaultAccountsExpandedRepository(
@ -107,14 +106,14 @@ internal object AccountDataModule {
userTokensSaver: UserTokensSaver,
accountTokenMigrationStore: AccountTokenMigrationStore,
eTagsStore: ETagsStore,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): DefaultMainAccountTokensMigration {
return DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
accountTokenMigrationStore = accountTokenMigrationStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
dispatchers = dispatchers,
coroutineScope = appScope,
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.data.common.currency.UserTokensSaver
@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.models.wallet.UserWalletId
import timber.log.Timber
import javax.inject.Inject
@ -35,7 +35,7 @@ import javax.inject.Singleton
@Singleton
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val userTokensSaver: UserTokensSaver,
private val userTokensResponseStore: UserTokensResponseStore,
private val userTokensResponseStore: LegacyUserTokensResponseStore,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val walletServerBinder: WalletServerBinder,
) {

View file

@ -1,71 +0,0 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.some
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
/**
* Default implementation of [MultiWalletCryptoCurrenciesProducer]
*
* @property params params
* @property flowProducerTools tools for producing flows
* @property userWalletsListRepository repository for getting user wallets
* @property userTokensResponseStore store of `UserTokensResponse`
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor(
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
override val flowProducerTools: FlowProducerTools,
private val userWalletsListRepository: UserWalletsListRepository,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesProducer {
override val fallback: Option<Set<CryptoCurrency>> = emptySet<CryptoCurrency>().some()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet")
}
return userTokensResponseStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
).toSet()
}
.onEmpty { emit(emptySet()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
override fun create(
params: MultiWalletCryptoCurrenciesProducer.Params,
): DefaultMultiWalletCryptoCurrenciesProducer
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.data.account.producer
import com.tangem.data.account.converter.AccountListConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.common.wallets.UserWalletsListRepository

View file

@ -10,11 +10,9 @@ import com.squareup.moshi.adapter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@ -35,7 +33,7 @@ typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
private val appScope: AppCoroutineScope,
) {
@OptIn(ExperimentalStdlibApi::class)
@ -53,7 +51,7 @@ internal class AccountsResponseStoreFactory @Inject constructor(
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.account.store
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
/**
* Legacy store for user tokens
*
* @property appPreferencesStore app preferences store
*
[REDACTED_AUTHOR]
*/
internal class LegacyUserTokensResponseStore @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
) {
suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse? {
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = createPreferencesKey(userWalletId = userWalletId.stringValue),
)
}
suspend fun clear(userWalletId: UserWalletId) {
appPreferencesStore.updateData { preferences ->
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
preferences.toMutablePreferences().apply { remove(key) }
}
}
private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId")
}

View file

@ -20,12 +20,10 @@ import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import timber.log.Timber
@ -43,11 +41,9 @@ internal class DefaultMainAccountTokensMigration(
private val accountTokenMigrationStore: AccountTokenMigrationStore,
private val userTokensSaver: UserTokensSaver,
private val eTagsStore: ETagsStore,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) : MainAccountTokensMigration {
private val coroutineScope = CoroutineScope(dispatchers.default + SupervisorJob())
internal suspend fun migrate(userWalletId: UserWalletId): Either<Throwable, GetWalletAccountsResponse> = either {
val store = accountsResponseStoreFactory.create(userWalletId)
val response = store.getSyncOrNull()

View file

@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.common.wallet.WalletServerBinder
@ -14,7 +15,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
@ -34,7 +34,7 @@ class FetchWalletAccountsErrorHandlerTest {
private val tangemTechApi: TangemTechApi = mockk()
private val walletServerBinder: WalletServerBinder = mockk()
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val userTokensResponseStore: LegacyUserTokensResponseStore = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val handler = FetchWalletAccountsErrorHandler(

View file

@ -1,378 +0,0 @@
package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
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.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultMultiWalletCryptoCurrenciesProducer(
params = params,
flowProducerTools = flowProducerTools,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsListRepository, userTokensResponseStore, responseCryptoCurrenciesFactory)
}
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val userTokensResponseFlow = flowOf<UserTokensResponse?>(null)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = emptySet<CryptoCurrency>()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
@Test
fun `flow will updated if UserTokensResponse is updated`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val updatedUserTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = listOf(
UserTokensResponse.Token(
id = null,
networkId = "bitcoin",
derivationPath = null,
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
contractAddress = null,
addresses = listOf(),
),
),
)
val updatedCryptoCurrencies = setOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns updatedCryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(updatedUserTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies)
Truth.assertThat(actual2.size).isEqualTo(2)
Truth.assertThat(actual2).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = expected1
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
}
@Disabled
@Test
fun `flow throws exception`() = runTest {
// Arrange
val exception = IllegalStateException()
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val innerFlow = MutableStateFlow(value = false)
val userTokensResponseFlow = flow {
if (innerFlow.value) {
emit(userTokensResponse)
} else {
throw exception
}
}
.buffer(capacity = 5)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = producer.fallback.getOrNull()
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
}
// Act 2 (emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = cryptoCurrencies
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@Test
fun `flow is empty if store returns empty flow`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = producer.fallback.getOrNull()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
@Test
fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest {
// Arrange
val mockUserWallet = mockk<UserWallet> {
every { walletId } returns userWallet.walletId
every { isMultiCurrency } returns false
}
val userWalletsFlow = MutableStateFlow(listOf(mockUserWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
// Act
val actual = runCatching { producer.produce() }.exceptionOrNull()
// Assert
val expected = IllegalStateException(
"${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet",
)
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder { userWalletsListRepository.userWallets }
verify(inverse = true) {
userTokensResponseStore.get(any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
private companion object {
val scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(2),
derivedKeys = emptyMap(),
)
val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse)
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.data.account.converter.AccountListConverter
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository

View file

@ -3,8 +3,8 @@ package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
@ -19,7 +19,7 @@ class AccountsResponseStoreFactoryTest {
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
context = context,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
appScope = TestAppCoroutineScope(),
)
@AfterEach

View file

@ -1,6 +1,7 @@
package com.tangem.data.account.token
import arrow.core.right
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
@ -18,7 +19,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.assertEither
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -43,7 +43,7 @@ class DefaultMainAccountTokensMigrationTest {
accountTokenMigrationStore = accountTokenMigrationStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
coroutineScope = TestAppCoroutineScope(),
)
private val userWalletId = UserWalletId("011")

View file

@ -1,78 +0,0 @@
package com.tangem.data.common.currency
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
/**
* Factory for creating list of [CryptoCurrency] for selected card
*
[REDACTED_AUTHOR]
*/
interface CardCryptoCurrencyFactory {
/**
* Universal method for creating list of [CryptoCurrency] in [network] for any card
*
* @param userWalletId user wallet id that determines type of card
* @param network network
*/
@Throws
suspend fun create(userWalletId: UserWalletId, network: Network): List<CryptoCurrency> {
return create(userWalletId = userWalletId, networks = setOf(network))[network].orEmpty()
}
/**
* Universal method for creating list of [CryptoCurrency] in [networks] for any card
*
* @param userWalletId user wallet id that determines type of card
* @param networks networks
*/
@Throws
suspend fun create(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, List<CryptoCurrency>>
/**
* Universal method for creating list of [CryptoCurrency] in [network] for any card
*
* @param userWalletId user wallet id that determines type of card
* @param network network
*/
@Throws
suspend fun createByRawId(userWalletId: UserWalletId, network: Network.RawID): List<CryptoCurrency>
/**
* Create currencies for multi currency card
*
* @param userWallet user wallet
* @param networks networks
*/
@Throws
suspend fun createCurrenciesForMultiCurrencyCard(
userWallet: UserWallet,
networks: Set<Network>,
): Map<Network, List<CryptoCurrency>>
/**
* Create default coins for multi currency card
*
* @param userWallet user wallet
*/
fun createDefaultCoinsForMultiCurrencyWallet(userWallet: UserWallet): List<CryptoCurrency.Coin>
/**
* Create primary currency for single currency card
*
* @param userWallet user wallet
*/
@Throws
fun createPrimaryCurrencyForSingleCurrencyCard(userWallet: UserWallet.Cold): CryptoCurrency
/**
* Create currencies for single currency card with token (like, NODL)
*
* @param userWallet user wallet
*/
@Throws
fun createCurrenciesForSingleCurrencyCardWithToken(userWallet: UserWallet.Cold): List<CryptoCurrency>
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.currency
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.account.WalletAccountsFetcher

View file

@ -1,14 +1,12 @@
package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.models.wallet.UserWallet
@ -23,31 +21,11 @@ import timber.log.Timber
class UserTokensSaver(
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val walletServerBinder: WalletServerBinder,
private val pushTokensRetryerPool: RetryerPool,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true)
store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
}
}
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.default) {
val updatedResponse = response
.applyCompatibility()
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse)
}
suspend fun push(
userWalletId: UserWalletId,
@ -111,10 +89,6 @@ class UserTokensSaver(
)
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}
private suspend fun UserTokensResponse.enrichIf(
userWalletId: UserWalletId,
condition: Boolean,

View file

@ -5,6 +5,7 @@ import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.cache.etag.DefaultETagsStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.*
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.common.quote.DefaultQuotesFetcher
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.data.common.wallet.DefaultWalletServerBinder
@ -12,8 +13,8 @@ import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletsRepository
@ -23,8 +24,6 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -67,19 +66,18 @@ internal object DataCommonModule {
fun provideUserTokensSaver(
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
addressesEnricher: UserTokensResponseAddressesEnricher,
walletServerBinder: WalletServerBinder,
appScope: AppCoroutineScope,
): UserTokensSaver {
return UserTokensSaver(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
addressesEnricher = addressesEnricher,
pushTokensRetryerPool = RetryerPool(
coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default),
coroutineScope = appScope,
),
walletServerBinder = walletServerBinder,
)

View file

@ -11,23 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
*/
class UserTokensBackwardCompatibility {
fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse {
return userTokensResponse.copy(
tokens = userTokensResponse.tokens.map { token ->
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]
if (oldSavedId != null && token.id == oldSavedId) {
Blockchain.fromNetworkId(token.networkId)?.let { blockchain ->
token.copy(
id = blockchain.toCoinId(),
)
} ?: token
} else {
token
}
},
)
}
fun applyCompatibilityAndGetUpdated(tokens: List<UserTokensResponse.Token>): List<UserTokensResponse.Token> {
return tokens.map { token ->
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]

View file

@ -6,7 +6,6 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -23,14 +22,12 @@ class UserTokensSaverTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
private val enricher: UserTokensResponseAddressesEnricher = mockk()
private val walletServerBinder: WalletServerBinder = mockk()
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
walletServerBinder = walletServerBinder,
@ -42,46 +39,11 @@ class UserTokensSaverTest {
clearMocks(
tangemTechApi,
userWalletsListRepository,
userTokensResponseStore,
enricher,
walletServerBinder,
)
}
@Test
fun `GIVEN user wallet id and response WHEN store THEN should store enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.MANUAL,
tokens = emptyList(),
)
coEvery { enricher(userWalletId, response) } returns enrichedResponse
// WHEN
userTokensSaver.store(userWalletId, response)
// THEN
coVerifyOrder {
enricher(userWalletId, response)
userTokensResponseStore.store(userWalletId, enrichedResponse)
}
coVerify(inverse = true) {
tangemTechApi.saveTokens(any(), any())
}
}
@Test
fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error and call onFailSend`() =
runTest {
@ -133,49 +95,4 @@ class UserTokensSaverTest {
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
}
@Test
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns "Wallet"
}
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
walletName = userWallet.name,
walletType = WalletType.COLD,
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
walletName = userWallet.name,
walletType = WalletType.COLD,
)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery {
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.storeAndPush(userWalletId, response)
// THEN
coVerifyOrder {
enricher(userWalletId, response)
userWalletsListRepository.userWallets
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
}
}
}

View file

@ -151,7 +151,7 @@ internal class DefaultQuotesFetcherTest {
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "ethereum",
timestamp = DateTime.now().millis - 10_000,
timestamp = DateTime.now().millis - 20_000,
value = cachedQuote,
),
),
@ -194,7 +194,7 @@ internal class DefaultQuotesFetcherTest {
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "ethereum",
timestamp = DateTime.now().millis - 10_000,
timestamp = DateTime.now().millis - 20_000,
value = cachedQuote,
),
QuoteMetadata(
@ -385,7 +385,7 @@ internal class DefaultQuotesFetcherTest {
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "solana",
timestamp = DateTime.now().millis - 10_000,
timestamp = DateTime.now().millis - 20_000,
value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO),
),
),

View file

@ -1,5 +1,6 @@
package com.tangem.data.feedback
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
@ -8,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
) : FeedbackFeatureToggles {
override val isUsedeskEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "USEDESK_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
}

View file

@ -10,7 +10,6 @@ import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.managetokens.utils.TokenAddressesConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.card.common.extensions.canHandleBlockchain
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.extensions.supportedBlockchains
@ -27,11 +26,9 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
@Suppress("LongParameterList")
internal class DefaultCustomTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val userTokensResponseStore: UserTokensResponseStore,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
private val networkFactory: NetworkFactory,
@ -64,27 +61,6 @@ internal class DefaultCustomTokensRepository(
}
}
override suspend fun isCurrencyNotAdded(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
contractAddress: String?,
): Boolean {
return withContext(dispatchers.io) {
val storedCurrencies = userTokensResponseStore.getSyncOrNull(userWalletId)
requireNotNull(storedCurrencies) {
"User tokens not found for user wallet [$userWalletId] while checking if currency is not added"
}
storedCurrencies.tokens.none { token ->
networkId.toBlockchain().toNetworkId() == token.networkId &&
derivationPath.value == token.derivationPath &&
contractAddress.equals(token.contractAddress, ignoreCase = true)
}
}
}
override suspend fun findToken(
userWalletId: UserWalletId,
contractAddress: String,

View file

@ -17,7 +17,6 @@ import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.orDefault
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.card.common.extensions.*
import com.tangem.domain.card.common.util.cardTypesResolver
@ -28,7 +27,6 @@ import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.pagination.BatchFetchResult
@ -44,7 +42,6 @@ internal class DefaultManageTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
private val userTokensResponseStore: UserTokensResponseStore,
private val testnetTokensStorage: TestnetTokensStorage,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
@ -280,52 +277,6 @@ internal class DefaultManageTokensRepository(
}
// endregion
override suspend fun hasLinkedTokens(
userWalletId: UserWalletId,
network: Network,
tempAddedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
tempRemovedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Boolean {
val addedTokens = tempAddedTokens.mapToResponseTokens()
val removedTokens = tempRemovedTokens.mapToResponseTokens()
val storedTokens = requireNotNull(
value = getSavedUserTokensResponseSync(userWalletId),
lazyMessage = { "Unable to find tokens response for user wallet with provided ID: $userWalletId" },
)
val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet()
return newTokensList.any { token ->
token.contractAddress != null &&
token.networkId == network.backendId &&
token.derivationPath == network.derivationPath.value
}
}
private fun Map<ManagedCryptoCurrency.Token, Set<Network>>.mapToResponseTokens(): List<UserTokensResponse.Token> {
return flatMap { (token, networks) ->
token.availableNetworks
.filter { sourceNetwork -> networks.contains(sourceNetwork.network) }
.map { sourceNetwork ->
val networkId = sourceNetwork.network.toBlockchain().toNetworkId()
UserTokensResponse.Token(
id = token.id.value,
networkId = networkId,
derivationPath = sourceNetwork.network.derivationPath.value,
name = token.name,
symbol = token.symbol,
decimals = sourceNetwork.decimals,
contractAddress = (sourceNetwork as? SourceNetwork.Default)?.contractAddress,
)
}
}
}
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
return userTokensResponseStore.getSyncOrNull(userWalletId = key)
}
override suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: SourceNetwork,

View file

@ -8,7 +8,6 @@ import com.tangem.data.managetokens.DefaultManageTokensRepository
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
@ -29,7 +28,6 @@ internal object ManageTokensDataModule {
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
userTokensResponseStore: UserTokensResponseStore,
testnetTokensStorage: TestnetTokensStorage,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
@ -40,7 +38,6 @@ internal object ManageTokensDataModule {
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
manageTokensUpdateFetcher = manageTokensUpdateFetcher,
userTokensResponseStore = userTokensResponseStore,
testnetTokensStorage = testnetTokensStorage,
excludedBlockchains = excludedBlockchains,
networkFactory = networkFactory,
@ -54,7 +51,6 @@ internal object ManageTokensDataModule {
fun provideCustomTokensRepository(
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
networkFactory: NetworkFactory,
@ -62,7 +58,6 @@ internal object ManageTokensDataModule {
return DefaultCustomTokensRepository(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
excludedBlockchains = excludedBlockchains,
dispatchers = dispatchers,
networkFactory = networkFactory,

View file

@ -4,7 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.repository.DefaultNetworksRepository
import com.tangem.data.networks.store.DefaultNetworksStatusesStore
import com.tangem.data.networks.store.NetworksStatusesStore
@ -15,6 +15,7 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -24,8 +25,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -37,7 +36,7 @@ internal object NetworkDataModule {
fun provideNetworksStatusesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): NetworksStatusesStore {
return DefaultNetworksStatusesStore(
context = context,
@ -49,9 +48,9 @@ internal object NetworkDataModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "networks_statuses_2") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
dispatchers = dispatchers,
scope = appScope,
)
}

View file

@ -2,7 +2,7 @@ package com.tangem.data.networks.multi
import arrow.core.raise.catch
import arrow.core.raise.ensure
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsCache

View file

@ -1,6 +1,6 @@
package com.tangem.data.networks.repository
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.storeStatus
import com.tangem.data.networks.utils.NetworkStatusFactory

View file

@ -7,11 +7,11 @@ import com.tangem.data.networks.converters.SimpleNetworkStatusConverter
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@ -33,11 +33,9 @@ internal class DefaultNetworksStatusesStore(
context: Context,
private val runtimeStore: RuntimeSharedStore<WalletIdWithSimpleStatus>,
private val persistenceDataStore: DataStore<WalletIdWithStatusDM>,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) : NetworksStatusesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
try {

View file

@ -3,7 +3,7 @@ package com.tangem.data.networks.multi
import arrow.core.Either
import arrow.core.left
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsCache

View file

@ -4,7 +4,7 @@ import com.google.common.truth.Truth
import com.tangem.blockchainsdk.models.UpdateWalletManagerResult
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.storeStatus
import com.tangem.data.networks.utils.NetworkStatusFactory

View file

@ -2,6 +2,7 @@ package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -27,7 +27,7 @@ internal class GetTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -2,6 +2,7 @@ package com.tangem.data.networks.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -10,7 +11,6 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
@ -33,7 +33,7 @@ internal class InitializationTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
@ -48,7 +48,7 @@ internal class InitializationTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<SimpleNetworkStatus>>())
@ -71,7 +71,7 @@ internal class InitializationTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
val expectedStatus = status.toSimple().copy(value = status.value.copySealed(source = StatusSource.CACHE))

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -31,7 +31,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -31,7 +31,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -30,7 +30,7 @@ internal class ParameterizedStoreTest(private val model: Model) {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -28,7 +28,7 @@ internal class SetSourceAsCacheTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -10,7 +11,6 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -28,7 +28,7 @@ internal class SetSourceAsOnlyCacheTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -10,7 +11,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -28,7 +28,7 @@ internal class StoreStatusTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -27,7 +27,7 @@ internal class StoreSuccessTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -9,7 +10,6 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -27,7 +27,7 @@ internal class StoreTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
@ -13,7 +14,6 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
@ -31,7 +31,7 @@ internal class UpdateStatusSourceTest {
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -22,6 +22,7 @@ import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.common.wallets.loadAndGet
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.HotCryptoCurrency
@ -30,10 +31,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import timber.log.Timber
/**
@ -59,9 +59,10 @@ internal class DefaultHotCryptoRepository(
private val walletAccountsFetcher: WalletAccountsFetcher,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
appScope: AppCoroutineScope,
) : HotCryptoRepository {
private val coroutineScope = CoroutineScope(dispatchers.main + SupervisorJob())
private val coroutineScope = appScope + dispatchers.main
private val hotCryptoJobHolder = JobHolder()

View file

@ -28,6 +28,7 @@ import com.tangem.datasource.local.onramp.sepa.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -109,6 +110,7 @@ internal object OnrampDataModule {
dispatchers: CoroutineDispatcherProvider,
analyticsEventHandler: AnalyticsEventHandler,
walletAccountsFetcher: WalletAccountsFetcher,
appScope: AppCoroutineScope,
): HotCryptoRepository {
return DefaultHotCryptoRepository(
excludedBlockchains = excludedBlockchains,
@ -119,6 +121,7 @@ internal object OnrampDataModule {
dispatchers = dispatchers,
analyticsEventHandler = analyticsEventHandler,
walletAccountsFetcher = walletAccountsFetcher,
appScope = appScope,
)
}

View file

@ -1,5 +1,8 @@
package com.tangem.data.qrscanning.di
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import dagger.Module
@ -15,6 +18,15 @@ internal object QrScanningDataModule {
@Provides
@Singleton
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
val blockchainDataProvider = QrContentClassifierParser.DefaultBlockchainDataProvider()
return DefaultQrScanningEventsRepository(
qrContentClassifierParser = QrContentClassifierParser(
blockchainDataProvider = blockchainDataProvider,
paymentUriParsers = setOf(
Eip681PaymentUriParser(blockchainDataProvider),
Bip321PaymentUriParser(blockchainDataProvider),
),
),
)
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.data.qrscanning.parser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
internal class Bip321PaymentUriParser(
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
) : PaymentUriParser {
override fun parse(
qrCode: String,
coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult {
val schemeAndRest = extractSchemeAndRest(qrCode, coins)
?: return PaymentUriParser.ParseResult.NotRecognized
val (matchingCoins, withoutScheme) = schemeAndRest
val parsed = QrSentUriParser().parse(withoutScheme)
?: return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
if (matchingCurrencies.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name,
),
)
}
return PaymentUriParser.ParseResult.Success(
ClassifiedQrContent.PaymentUri(
address = parsed.address,
amount = parsed.amount,
memo = parsed.memo,
matchingCurrencies = matchingCurrencies,
),
)
}
private fun extractSchemeAndRest(
qrCode: String,
coins: List<CryptoCurrency.Coin>,
): Pair<List<CryptoCurrency.Coin>, String>? {
for (coin in coins) {
val schemes = blockchainDataProvider.getShareSchemes(coin.network)
for (scheme in schemes) {
if (qrCode.startsWith(scheme, ignoreCase = true)) {
val withoutScheme = qrCode.removeRange(0, scheme.length)
val allMatchingCoins = coins.filter { c ->
blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) }
}
return allMatchingCoins to withoutScheme
}
}
}
return null
}
}

View file

@ -0,0 +1,178 @@
package com.tangem.data.qrscanning.parser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import java.math.BigDecimal
import java.math.MathContext
internal class Eip681PaymentUriParser(
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
) : PaymentUriParser {
override fun parse(
qrCode: String,
coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult {
if (!qrCode.startsWith(SCHEME)) {
return PaymentUriParser.ParseResult.NotRecognized
}
val withoutScheme = qrCode.removePrefix(SCHEME)
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
if (matchingCoins.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = parsed.chainId?.let { blockchainDataProvider.getBlockchainNameByChainId(it) },
),
)
}
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
if (PARAM_ADDRESS !in parsed.params) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
resolveErc20Transfer(parsed, matchingCoins, allCurrencies)
} else {
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
}
return if (result != null) {
PaymentUriParser.ParseResult.Success(result)
} else {
PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name,
),
)
}
}
private fun resolveNativeTransfer(
parsed: Eip681Result,
matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? {
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
if (matchingCoins.isEmpty()) return null
val decimals = matchingCoins.first().decimals
val amount = valueWei?.fromSmallestUnit(decimals)
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
// If value is specified, this is a native coin transfer — return only coins
// If no value, it's just an address with scheme — return all currencies on the network
val matchingCurrencies = if (valueWei != null) {
matchingCoins
} else {
allCurrencies.filter { it.network.id in matchingNetworkIds }
}
return ClassifiedQrContent.PaymentUri(
address = parsed.targetAddress,
amount = amount,
memo = null,
matchingCurrencies = matchingCurrencies,
)
}
private fun resolveErc20Transfer(
parsed: Eip681Result,
matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? {
val recipient = parsed.params[PARAM_ADDRESS] ?: return null
val contractAddress = parsed.targetAddress
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
val matchingTokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
.filter { token ->
token.network.id in matchingNetworkIds &&
token.contractAddress.equals(contractAddress, ignoreCase = true)
}
if (matchingTokens.isEmpty()) return null
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
return ClassifiedQrContent.PaymentUri(
address = recipient,
amount = amount,
memo = null,
matchingCurrencies = matchingTokens,
)
}
private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
if (chainId == null) {
return coins.filter { coin ->
blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) }
}
}
return coins.filter { coin ->
blockchainDataProvider.getChainId(coin.network) == chainId
}
}
private fun parseEip681(withoutScheme: String): Eip681Result? {
val match = URI_REGEX.matchEntire(withoutScheme) ?: return null
val targetAddress = match.groupValues[GROUP_ADDRESS].ifBlank { return null }
val pathChainId = match.groupValues[GROUP_CHAIN_ID].toLongOrNull()
val functionName = match.groupValues[GROUP_FUNCTION].ifBlank { null }
val queryString = match.groupValues[GROUP_QUERY]
val params = parseQueryParams(queryString)
val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull()
return Eip681Result(
targetAddress = targetAddress,
chainId = chainId,
functionName = functionName,
params = params,
)
}
private fun parseQueryParams(query: String): Map<String, String> {
if (query.isBlank()) return emptyMap()
return query.split('&').mapNotNull { param ->
val parts = param.split('=', limit = 2)
if (parts.size == 2) parts[0] to parts[1] else null
}.toMap()
}
private fun BigDecimal.fromSmallestUnit(decimals: Int): BigDecimal {
if (decimals == 0) return this
return this.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128)
}
private data class Eip681Result(
val targetAddress: String,
val chainId: Long?,
val functionName: String?,
val params: Map<String, String>,
)
private companion object {
// ethereum:<address>[@<chainId>][/<function>][?<params>]
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
const val SCHEME = "ethereum:"
const val FUNCTION_TRANSFER = "transfer"
const val PARAM_VALUE = "value"
const val PARAM_ADDRESS = "address"
const val PARAM_UINT256 = "uint256"
const val PARAM_CHAIN_ID = "chainId"
const val GROUP_ADDRESS = 1
const val GROUP_CHAIN_ID = 2
const val GROUP_FUNCTION = 3
const val GROUP_QUERY = 4
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.qrscanning.parser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
internal interface PaymentUriParser {
fun parse(qrCode: String, coins: List<CryptoCurrency.Coin>, allCurrencies: List<CryptoCurrency>): ParseResult
sealed class ParseResult {
/** URI format not recognized by this parser. */
data object NotRecognized : ParseResult()
/** URI format recognized but resulted in an error. */
data class RecognizedError(val error: ClassifiedQrContent.Error) : ParseResult()
/** Successfully parsed with matching currencies. */
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult()
}
}

View file

@ -0,0 +1,123 @@
package com.tangem.data.qrscanning.parser
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import java.net.URLDecoder
internal class QrContentClassifierParser(
private val blockchainDataProvider: BlockchainDataProvider,
private val paymentUriParsers: Set<PaymentUriParser>,
) {
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
if (qrCode.startsWith(WC_PREFIX)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
if (isDAppWcUrl(qrCode)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
val uniqueCoins = coins.distinctBy { it.network.id }
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error
is PaymentUriParser.ParseResult.NotRecognized -> Unit
}
val matchingCoins = uniqueCoins
.filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) }
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
if (matchingNetworkIds.isNotEmpty()) {
val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds }
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
val supportedBlockchain = blockchainDataProvider.findSupportedBlockchainName(qrCode)
if (supportedBlockchain != null) {
return ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = supportedBlockchain,
)
}
return ClassifiedQrContent.Error.Unrecognized(qrCode)
}
private fun tryParsePaymentUri(
qrCode: String,
coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult {
return paymentUriParsers.firstNotNullOfOrNull { parser ->
parser.parse(qrCode, coins, allCurrencies).takeUnless { it is PaymentUriParser.ParseResult.NotRecognized }
} ?: PaymentUriParser.ParseResult.NotRecognized
}
private fun isDAppWcUrl(qrCode: String): Boolean {
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
val uriParser = QrSentUriParser()
val uriParam = uriParser.extractParameters(qrCode)[PARAM_URI] ?: return false
val decodedUri = runCatching { URLDecoder.decode(
uriParam,
QrSentUriParser.CHARSET_UTF8,
) }.getOrDefault(uriParam)
return decodedUri.startsWith(WC_PREFIX)
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
fun getChainId(network: Network): Long?
fun findSupportedBlockchainName(address: String): String?
fun getBlockchainNameByChainId(chainId: Long): String?
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
override fun getShareSchemes(network: Network): List<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
}
override fun getChainId(network: Network): Long? {
return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull()
}
override fun findSupportedBlockchainName(address: String): String? {
return Blockchain.entries
.filter { !it.isTestnet() }
.firstOrNull { blockchain ->
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
}?.fullName
}
override fun getBlockchainNameByChainId(chainId: Long): String? {
return Blockchain.entries
.filter { !it.isTestnet() }
.firstOrNull { blockchain ->
runCatching { blockchain.getChainId()?.toLong() == chainId }.getOrDefault(false)
}?.fullName
}
}
private companion object {
const val HTTP_PREFIX = "http://"
const val HTTPS_PREFIX = "https://"
const val PARAM_URI = "uri"
const val WC_PREFIX = "wc:"
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.qrscanning.parser
import java.math.BigDecimal
import java.net.URLDecoder
internal class QrSentUriParser {
data class Result(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val params: Map<String, String>,
)
fun parse(withoutScheme: String): Result? {
val address = withoutScheme.takeWhile {
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
}
if (address.isBlank()) return null
val params = extractParameters(withoutScheme)
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
}
return Result(
address = address,
amount = amount,
memo = memo,
params = params,
)
}
fun extractParameters(from: String): Map<String, String> {
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
if (paramsBlock.isBlank()) return emptyMap()
return paramsBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
}
.toMap()
}
companion object {
const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/'
const val PARAM_DELIMITER = '?'
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
const val PARAM_AMOUNT = "amount"
const val PARAM_MEMO = "memo"
const val PARAM_MESSAGE = "message"
const val PARAM_ADDRESS = "address"
const val PARAM_VALUE = "value"
const val PARAM_UINT256 = "uint256"
const val CHARSET_UTF8 = "UTF-8"
}
}

View file

@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.data.qrscanning.parser.QrSentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.RawQrResult
import com.tangem.domain.qrscanning.models.SourceType
@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.yield
import java.math.BigDecimal
import java.net.URLDecoder
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
internal class DefaultQrScanningEventsRepository(
private val qrContentClassifierParser: QrContentClassifierParser,
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
) : QrScanningEventsRepository {
private data class QrScanningEvent(val qrCode: RawQrResult)
@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
val parsed = paymentUriParser.parse(withoutSchema)
?: return QrResult(address = withoutSchema)
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
//
// We're extracting the destination address by parsing the given string until we meet
// any of the possible string delimiters (@ ? /).
val address = withoutSchema.takeWhile { char ->
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
val result = QrResult(address = parsed.address)
result.amount = parsed.amount
result.memo = parsed.memo
// ERC-681: if 'address' parameter exists, currency must be a token,
// and the URI address must match the token's contract address.
parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
result.address = addressValue
} else {
return QrResult()
}
}
val result = QrResult(address = address)
extractParameters(withoutSchema)
.forEach { entry ->
when (entry.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = entry.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
-> {
result.memo = URLDecoder.decode(entry.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
// Overrides destination address for token transfers (ERC-681)
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = entry.value
} else {
return QrResult()
}
}
Parameter.Value,
Parameter.Uint256,
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = entry.value.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}
}
}
// ERC-681: value/uint256 is in the smallest unit, needs conversion
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
?: parsed.params[QrSentUriParser.PARAM_UINT256]
if (valueStr != null) {
result.amount = valueStr.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}
return result
}
override fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
return qrContentClassifierParser.parse(qrCode, userCurrencies)
}
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
val qrSchemas = currency.network.toBlockchain().getShareScheme()
// The most specific (i.e. the most lengthy) prefixes always come first
qrSchemas
.sortedByDescending { it.length }
.forEach { schema ->
val stripped = raw.split(schema)
if (stripped.size > 1) return stripped.last()
}
return raw
}
private fun extractParameters(from: String): Map<Parameter, String> {
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
if (parametersBlock.isBlank()) return emptyMap()
val paramList = parametersBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
if (parameterWithValue.size == 2) {
val name = Parameter.getParam(parameterWithValue.first())
val value = parameterWithValue.last()
if (name != null) {
name to value
} else {
null
}
} else {
null
}
}.associate { it }
return paramList
}
private enum class Parameter {
Amount,
Message,
Memo,
Address,
Value,
Uint256,
;
companion object {
fun getParam(name: String): Parameter? {
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
}
}
private companion object {
// See https://eips.ethereum.org/EIPS/eip-681 for details.
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
}
}

View file

@ -0,0 +1,269 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth.assertThat
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
import com.tangem.data.qrscanning.parser.PaymentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class Bip321PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
}
private val parser = Bip321PaymentUriParser(blockchainDataProvider)
// region Basic parsing
@Test
fun `bitcoin URI with address and amount`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(result.amount!!.compareTo(BigDecimal("0.5"))).isEqualTo(0)
assertThat(result.memo).isNull()
assertThat(result.matchingCurrencies).containsExactly(bitcoinCoin)
}
@Test
fun `bitcoin URI with address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(result.amount).isNull()
assertThat(result.memo).isNull()
}
@Test
fun `bitcoin URI with amount and message`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(result.amount!!.compareTo(BigDecimal("1.23"))).isEqualTo(0)
assertThat(result.memo).isEqualTo("Donation")
}
@Test
fun `bitcoin URI with label and message`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.memo).isEqualTo("Payment")
}
// endregion
// region Scheme matching
@Test
fun `litecoin URI matches litecoin coin`() {
every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:")
val result = parser.parse(
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
coins = listOf(litecoinCoin),
allCurrencies = listOf(litecoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj")
assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0)
assertThat(result.matchingCurrencies).containsExactly(litecoinCoin)
}
@Test
fun `no matching scheme returns NotRecognized`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "dogecoin:DAddress?amount=100",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
@Test
fun `ethereum scheme matches as Success`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val result = parser.parse(
qrCode = "ethereum:0xRecipient?value=1000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
).asSuccess()
assertThat(result).isNotNull()
}
@Test
fun `case insensitive scheme matching`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
// region Includes tokens on matching network
@Test
fun `includes tokens on matching network`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin, btcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.matchingCurrencies).containsExactly(bitcoinCoin, btcToken)
}
// endregion
// region Edge cases
@Test
fun `empty qr code returns NotRecognized`() {
val result = parser.parse(
qrCode = "",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
@Test
fun `plain address returns NotRecognized`() {
val result = parser.parse(
qrCode = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
@Test
fun `bitcoin URI with memo param`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.memo).isEqualTo("TestMemo")
}
// endregion
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return (this as? PaymentUriParser.ParseResult.Success)?.content
}
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
private val litecoinCoin = buildCoin("litecoin", decimals = 8)
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
),
network = buildNetwork(rawNetworkId),
name = symbol,
symbol = symbol,
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.qrscanning
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -13,7 +14,8 @@ import java.math.BigDecimal
internal class DefaultQrScanningEventsRepositoryTest {
private val repository = DefaultQrScanningEventsRepository()
private val qrContentClassifier: QrContentClassifierParser = mockk()
private val repository = DefaultQrScanningEventsRepository(qrContentClassifier)
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
private val network = mockk<Network>()

View file

@ -0,0 +1,328 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth.assertThat
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
import com.tangem.data.qrscanning.parser.PaymentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class Eip681PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { getChainId(any()) } returns null
every { getBlockchainNameByChainId(any()) } returns null
}
private val parser = Eip681PaymentUriParser(blockchainDataProvider)
// region Native transfer
@Test
fun `native transfer with chain_id and value`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1?value=1500000000000000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0xRecipient")
assertThat(result.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0)
assertThat(result.memo).isNull()
assertThat(result.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `native transfer without value`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0xRecipient")
assertThat(result.amount).isNull()
}
@Test
fun `native transfer without chain_id falls back to scheme matching`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val result = parser.parse(
qrCode = "ethereum:0xRecipient?value=1000000000000000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0xRecipient")
assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0)
}
@Test
fun `native transfer with value returns only coins`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `native transfer without value returns all currencies on matching network`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
}
// endregion
// region ERC-20 transfer
@Test
fun `ERC-20 transfer with contract, recipient and amount`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0xRecipient")
assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0)
assertThat(result.matchingCurrencies).containsExactly(usdcToken)
}
@Test
fun `ERC-20 transfer without amount`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0xRecipient")
assertThat(result.amount).isNull()
assertThat(result.matchingCurrencies).containsExactly(usdcToken)
}
@Test
fun `ERC-20 transfer with unknown token returns UnsupportedNetwork error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xUnknownContract@1/transfer?address=0xRecipient&uint256=1000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
}
@Test
fun `ERC-20 transfer without address param returns Unrecognized error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?uint256=1000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
@Test
fun `ERC-20 transfer with chainId as query param`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdtToken = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7")
val result = parser.parse(
qrCode = "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7/transfer?address=0x3D709aC89d780312677519c3AfC13f390C819531&uint256=30000000&chainId=1",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdtToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("0x3D709aC89d780312677519c3AfC13f390C819531")
assertThat(result.amount!!.compareTo(BigDecimal("30"))).isEqualTo(0)
assertThat(result.matchingCurrencies).containsExactly(usdtToken)
}
// region Chain ID matching
@Test
fun `chain_id mismatch returns UnsupportedNetwork error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@137?value=1000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
}
@Test
fun `chain_id matches correct network among multiple`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
every { blockchainDataProvider.getChainId(polygonCoin.network) } returns 137L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@137?value=1000000000000000000",
coins = listOf(ethereumCoin, polygonCoin),
allCurrencies = listOf(ethereumCoin, polygonCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.matchingCurrencies).containsExactly(polygonCoin)
}
// endregion
// region Non-ethereum schemes
@Test
fun `non-ethereum scheme returns NotRecognized`() {
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
@Test
fun `empty qr code returns NotRecognized`() {
val result = parser.parse(
qrCode = "",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
@Test
fun `ethereum scheme with blank address returns NotRecognized`() {
val result = parser.parse(
qrCode = "ethereum:?value=1000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
}
// endregion
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return (this as? PaymentUriParser.ParseResult.Success)?.content
}
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
private val polygonCoin = buildCoin("polygon", decimals = 18)
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
),
network = buildNetwork(rawNetworkId),
name = symbol,
symbol = symbol,
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}

View file

@ -0,0 +1,252 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth.assertThat
import com.tangem.data.qrscanning.parser.PaymentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
every { getChainId(any()) } returns null
every { findSupportedBlockchainName(any()) } returns null
every { getBlockchainNameByChainId(any()) } returns null
}
private val paymentUriParser = mockk<PaymentUriParser> {
every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized
}
private val classifier = QrContentClassifierParser(
blockchainDataProvider = blockchainDataProvider,
paymentUriParsers = setOf(paymentUriParser),
)
// region WalletConnect
@Test
fun `WalletConnect URI is classified correctly`() {
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
val result = classifier.parse(uri, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
}
@Test
fun `WalletConnect URI takes priority over address matching`() {
val uri = "wc:something"
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
}
@Test
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
val result = classifier.parse(dAppUrl, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
}
@Test
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
val url = "https://example.com/page?foo=bar"
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
val url = "https://example.com/page?uri=https://other.com"
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
// region PaymentUri delegation
@Test
fun `PaymentUri is returned when parser matches`() {
val expectedUri = ClassifiedQrContent.PaymentUri(
address = "0xRecipient",
amount = BigDecimal("1.5"),
memo = null,
matchingCurrencies = listOf(ethereumCoin),
)
every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri)
val result = classifier.parse("ethereum:0xRecipient@1?value=1500000000000000000", listOf(ethereumCoin))
assertThat(result).isEqualTo(expectedUri)
}
@Test
fun `PaymentUri takes priority over plain address match`() {
val expectedUri = ClassifiedQrContent.PaymentUri(
address = "0xRecipient",
amount = null,
memo = null,
matchingCurrencies = listOf(ethereumCoin),
)
every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri)
every { blockchainDataProvider.validateAddress(ethereumCoin.network, any()) } returns true
val result = classifier.parse("ethereum:0xRecipient", listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
}
// endregion
// region PlainAddress
@Test
fun `Plain address matches single currency`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.parse(address, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.address).isEqualTo(address)
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `Plain address matches multiple currencies`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
val result = classifier.parse(address, listOf(ethereumCoin, bscCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(2)
}
@Test
fun `PlainAddress includes tokens on matching networks`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = classifier.parse(address, listOf(ethereumCoin, usdcToken))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
}
// endregion
// region Unknown
@Test
fun `Random string returns Unknown`() {
val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
assertThat((result as ClassifiedQrContent.Error.Unrecognized).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.parse("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
fun `Tokens alone without Coins cannot match addresses`() {
val token = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7")
val result = classifier.parse("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
// region Helpers
private val bitcoinCoin = buildCoin("bitcoin")
private val ethereumCoin = buildCoin("ethereum")
private val bscCoin = buildCoin("bsc")
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = 8,
iconUrl = null,
isCustom = false,
)
}
private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token {
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
),
network = buildNetwork(rawNetworkId),
name = symbol,
symbol = symbol,
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}

View file

@ -14,17 +14,15 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -36,7 +34,7 @@ internal object QuotesDataModule {
fun provideQuotesStoreV2(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): QuotesStatusesStore {
return DefaultQuotesStatusesStore(
runtimeStore = RuntimeSharedStore(),
@ -47,9 +45,9 @@ internal object QuotesDataModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
dispatchers = dispatchers,
scope = appScope,
)
}
@ -65,13 +63,13 @@ internal object QuotesDataModule {
appCurrencyResponseStore: AppCurrencyResponseStore,
quotesStatusesStore: QuotesStatusesStore,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
dispatchers: CoroutineDispatcherProvider,
coroutineScope: AppCoroutineScope,
): MultiQuoteUpdater {
return DefaultMultiQuoteUpdater(
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStatusesStore = quotesStatusesStore,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
dispatchers = dispatchers,
coroutineScope = coroutineScope,
)
}
}

View file

@ -4,16 +4,14 @@ import androidx.annotation.VisibleForTesting
import arrow.core.left
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import timber.log.Timber
@ -32,10 +30,9 @@ internal class DefaultMultiQuoteUpdater(
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStatusesStore: QuotesStatusesStore,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) : MultiQuoteUpdater {
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val updaterHolder = JobHolder()
override fun subscribe() {

View file

@ -4,13 +4,11 @@ import androidx.datastore.core.DataStore
import com.tangem.data.quotes.converter.QuoteStatusConverter
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
@ -29,11 +27,9 @@ internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
internal class DefaultQuotesStatusesStore(
private val runtimeStore: RuntimeSharedStore<Set<QuoteStatus>>,
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) : QuotesStatusesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceDataStore.data.firstOrNull()

View file

@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
@ -26,7 +26,7 @@ internal class DefaultMultiQuoteStatusUpdaterTest {
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStatusesStore = quotesStore,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
coroutineScope = TestAppCoroutineScope(),
)
@Test

View file

@ -1,6 +1,7 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
@ -9,7 +10,6 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@ -39,7 +39,7 @@ internal class QuotesStatusesStoreExtTest {
store = DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
@ -11,7 +12,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
@ -53,7 +53,7 @@ internal class QuotesStatusesStoreTest {
store = DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
}
@ -73,7 +73,7 @@ internal class QuotesStatusesStoreTest {
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
val actual = runtimeStore.getSyncOrNull()
@ -93,7 +93,7 @@ internal class QuotesStatusesStoreTest {
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
val actual = runtimeStore.getSyncOrNull()
@ -120,7 +120,7 @@ internal class QuotesStatusesStoreTest {
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
val actual = runtimeStore.getSyncOrNull()

View file

@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -27,12 +27,12 @@ internal object StakingBalanceSupplierModule {
@Singleton
fun provideStakingBalancesStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
): StakeKitBalancesStore {
return DefaultStakeKitBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
scope = scope,
)
}
@ -40,12 +40,12 @@ internal object StakingBalanceSupplierModule {
@Singleton
fun provideP2PEthPoolBalancesStore(
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
): P2PEthPoolBalancesStore {
return DefaultP2PEthPoolBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
scope = scope,
)
}

View file

@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
@ -33,11 +31,9 @@ internal typealias WalletIdWithP2PEthPoolResponses = Map<String, Set<P2PEthPoolA
internal class DefaultP2PEthPoolBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithP2PEthPoolResponses>,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) : P2PEthPoolBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedData = persistenceStore.data.firstOrNull() ?: return@launch

View file

@ -4,14 +4,12 @@ import androidx.datastore.core.DataStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
@ -33,11 +31,9 @@ internal typealias WalletIdWithStakingBalances = Map<UserWalletId, Set<StakingBa
internal class DefaultStakeKitBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithWrappers>,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) : StakeKitBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch

View file

@ -1,5 +1,6 @@
package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.toggles.StakingFeatureToggles
@ -8,5 +9,5 @@ internal class DefaultStakingFeatureToggles(
) : StakingFeatureToggles {
override val isEthStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED)
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
@ -8,7 +9,6 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -23,7 +23,7 @@ internal class StakingBalancesStoreGetMethodTest {
private val store = DefaultStakeKitBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -2,13 +2,13 @@ package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
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.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
@ -30,7 +30,7 @@ internal class StakingBalancesStoreInitializationTest {
DefaultStakeKitBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
@ -44,7 +44,7 @@ internal class StakingBalancesStoreInitializationTest {
DefaultStakeKitBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<StakingBalance>>())
@ -66,7 +66,7 @@ internal class StakingBalancesStoreInitializationTest {
DefaultStakeKitBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
val expected = mapOf(userWalletId to setOf(wrapper.toDomain()))

View file

@ -1,6 +1,7 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
@ -10,7 +11,6 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -26,7 +26,7 @@ internal class StakingBalancesStoreUpdateMethodsTest {
private val store = DefaultStakeKitBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
scope = TestAppCoroutineScope(),
)
@Test

View file

@ -32,6 +32,7 @@ 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.swap.models.SwapAmountType
import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
@ -177,12 +178,26 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
userWallet: UserWallet,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAmount: BigDecimal,
amount: BigDecimal,
amountType: SwapAmountType,
provider: ExpressProvider,
rateType: ExpressRateType,
): SwapQuoteModel = withContext(coroutineDispatcher.io) {
val response = tangemExpressApi.getExchangeQuote(
fromAmount = fromAmount.movePointRight(fromCryptoCurrency.decimals).toString(),
fromAmount = if (amountType == SwapAmountType.From) {
amount.movePointRight(
fromCryptoCurrency.decimals,
).toString()
} else {
null
},
toAmount = if (amountType == SwapAmountType.To) {
amount.movePointRight(
toCryptoCurrency.decimals,
).toString()
} else {
null
},
fromNetwork = fromCryptoCurrency.network.backendId,
fromContractAddress = fromCryptoCurrency.getContractAddress(),
fromDecimals = fromCryptoCurrency.decimals,
@ -199,10 +214,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
).getOrThrow()
val toTokenAmount = requireNotNull(response.toAmount.toBigDecimalOrNull()?.movePointLeft(response.toDecimals))
val fromTokenAmount = response.fromAmount.toBigDecimalOrNull()?.movePointLeft(response.fromDecimals)
return@withContext SwapQuoteModel(
provider = provider,
toTokenAmount = toTokenAmount,
fromTokenAmount = fromTokenAmount,
allowanceContract = response.allowanceContract,
)
}
@ -211,7 +228,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrency: CryptoCurrency,
fromAmount: String,
amount: String,
amountType: SwapAmountType,
toAddress: String,
toExtraId: String?,
expressProvider: ExpressProvider,
@ -241,7 +259,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
toAddress = toAddress,
fromDecimals = fromCurrency.decimals,
toDecimals = toCryptoCurrency.decimals,
fromAmount = fromAmount,
fromAmount = if (amountType == SwapAmountType.From) amount else null,
toAmount = if (amountType == SwapAmountType.To) amount else null,
providerId = expressProvider.providerId,
rateType = rateType.name.lowercase(),
requestId = requestId,

View file

@ -26,6 +26,7 @@ dependencies {
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.card)
implementation(projects.domain.common)
implementation(projects.domain.core)
implementation(projects.domain.demo)
implementation(projects.domain.express)

View file

@ -1,35 +1,30 @@
package com.tangem.data.tokens
import arrow.core.Either
import arrow.core.right
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.api.tangemTech.models.account.flattenTokens
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.domain.tokens.MultiWalletAccountListFetcher.Params
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Implementation of [MultiWalletCryptoCurrenciesFetcher] that fetches crypto currencies of all accounts
* Implementation of [MultiWalletAccountListFetcher] that fetches the account list for a multi-currency wallet
* by delegating to [WalletAccountsFetcher].
*
* @property userWalletsListRepository repository to get user wallets
* @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet
* @property expressServiceFetcher fetcher of express service
* @property dispatchers dispatchers
* @property dispatchers provider for coroutine dispatchers used to run fetch operations
*
[REDACTED_AUTHOR]
*/
internal class AccountListCryptoCurrenciesFetcher(
private val userWalletsListRepository: UserWalletsListRepository,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val expressServiceFetcher: ExpressServiceFetcher,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher {
) : MultiWalletAccountListFetcher {
override suspend fun invoke(params: Params): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.default) {
@ -37,16 +32,7 @@ internal class AccountListCryptoCurrenciesFetcher(
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
val response = walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
expressServiceFetcher.fetch(
userWallet = userWallet,
assetIds = response.flattenTokens().mapTo(hashSetOf()) {
ExpressAsset.ID(networkId = it.networkId, contractAddress = it.contractAddress)
},
)
Unit.right()
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
}
}
}

View file

@ -3,8 +3,7 @@ package com.tangem.data.tokens.di
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -14,20 +13,18 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class MultiWalletCryptoCurrenciesFetcherModule {
internal class MultiWalletAccountListFetcherModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesFetcher(
fun provideMultiWalletAccountListFetcher(
userWalletsListRepository: UserWalletsListRepository,
walletAccountsFetcher: WalletAccountsFetcher,
expressServiceFetcher: ExpressServiceFetcher,
dispatchers: CoroutineDispatcherProvider,
): MultiWalletCryptoCurrenciesFetcher {
): MultiWalletAccountListFetcher {
return AccountListCryptoCurrenciesFetcher(
userWalletsListRepository = userWalletsListRepository,
walletAccountsFetcher = walletAccountsFetcher,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
)
}

View file

@ -1,9 +1,6 @@
package com.tangem.data.tokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository
@ -11,10 +8,7 @@ import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedReposit
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.TokenReceiveWarningActionStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
@ -35,29 +29,17 @@ internal object TokensDataModule {
@Singleton
fun provideCurrenciesRepository(
tangemTechApi: TangemTechApi,
userTokensResponseStore: UserTokensResponseStore,
userWalletsListRepository: UserWalletsListRepository,
walletManagersFacade: WalletManagersFacade,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
expressServiceFetcher: ExpressServiceFetcher,
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): CurrenciesRepository {
return DefaultCurrenciesRepository(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
walletManagersFacade = walletManagersFacade,
cacheRegistry = cacheRegistry,
userTokensResponseStore = userTokensResponseStore,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
)
}

View file

@ -5,172 +5,33 @@ import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.getTokenId
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.error.DataError
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.account.DerivationIndex
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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import timber.log.Timber
import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultCurrenciesRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val walletManagersFacade: WalletManagersFacade,
private val cacheRegistry: CacheRegistry,
private val expressServiceFetcher: ExpressServiceFetcher,
private val dispatchers: CoroutineDispatcherProvider,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
excludedBlockchains: ExcludedBlockchains,
) : CurrenciesRepository {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
if (userWallet.isMultiCurrency) {
getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send)
} else {
val currencies = getSingleCurrencyWalletWithCardCurrencies(userWalletId)
send(currencies)
}
}
}
override suspend fun getSingleCurrencyWalletPrimaryCurrency(
userWalletId: UserWalletId,
refresh: Boolean,
): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
userWallet.requireColdWallet()
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(
userWallet = userWallet,
)
fetchExpressAssetsByNetworkIds(
userWallet = userWallet,
cryptoCurrencies = listOf(currency),
refresh = refresh,
)
currency
}
}
override suspend fun getSingleCurrencyWalletWithCardCurrencies(
userWalletId: UserWalletId,
refresh: Boolean,
): List<CryptoCurrency> {
return withContext(dispatchers.io) {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val scanResponse = userWallet.requireColdWallet().scanResponse
val currencies = if (scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet)
} else {
cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet)
.run {
listOf(this)
}
}
fetchExpressAssetsByNetworkIds(
userWallet = userWallet,
cryptoCurrencies = currencies,
refresh = refresh,
)
currencies
}
}
override suspend fun getSingleCurrencyWalletWithCardCurrency(
userWalletId: UserWalletId,
id: CryptoCurrency.ID,
): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
userWallet.requireColdWallet()
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
val currency = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(
userWallet = userWallet,
)
.find { it.id == id }
requireNotNull(currency) { "Unable to find currency with provided ID: $id" }
fetchExpressAssetsByNetworkIds(userWallet, listOf(currency))
currency
}
}
private fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
getMultiCurrencyWalletCurrencies(userWallet)
.onEach { send(it) }
.launchIn(scope = this + dispatchers.io)
}
}
override suspend fun getNetworkCoin(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin {
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.find { currency ->
currency is CryptoCurrency.Coin &&
currency.network.id.rawId == networkId.rawId &&
currency.network.derivationPath == derivationPath
} as? CryptoCurrency.Coin
?: error("Unable to find coin for network ID: $networkId")
}
override suspend fun isSendBlockedByPendingTransactions(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
@ -276,70 +137,4 @@ internal class DefaultCurrenciesRepository(
val blockchain = Blockchain.fromNetworkId(network.backendId)
return blockchain?.isNetworkFeeZero() == true
}
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver? {
return (userWalletsListRepository.getSyncStrict(userWalletId) as? UserWallet.Cold)?.cardTypesResolver
}
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
responseCryptoCurrenciesFactory.createCurrencies(
response = storedTokens,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
private suspend fun fetchExpressAssetsByNetworkIds(
userWallet: UserWallet,
cryptoCurrencies: List<CryptoCurrency>,
refresh: Boolean = false,
) {
val tokens = cryptoCurrencies.mapTo(hashSetOf()) { currency ->
val tokenCurrency = currency as? CryptoCurrency.Token
ExpressAsset.ID(
networkId = currency.network.backendId,
contractAddress = tokenCurrency?.contractAddress,
)
}
cacheRegistry.invokeOnExpire(
key = getAssetsCacheKey(userWallet.walletId),
skipCache = refresh,
block = {
coroutineScope {
launch { expressServiceFetcher.fetch(userWallet, tokens) }
}
},
)
}
private fun getAssetsCacheKey(userWalletId: UserWalletId): String = "assets_cache_key_${userWalletId.stringValue}"
private fun ensureIsCorrectUserWallet(userWallet: UserWallet, isMultiCurrencyWalletExpected: Boolean) {
val userWalletId = userWallet.walletId
val message = when {
!userWallet.isMultiCurrency && isMultiCurrencyWalletExpected -> {
"Multi currency wallet expected, but single currency wallet was found: $userWalletId"
}
userWallet.isMultiCurrency && !isMultiCurrencyWalletExpected -> {
"Single currency wallet expected, but multi currency wallet was found: $userWalletId"
}
else -> null
}
if (message != null) {
val error = DataError.UserWalletError.WrongUserWallet(message)
Timber.e(error)
throw error
}
}
private fun getSavedUserTokensResponse(key: UserWalletId): Flow<UserTokensResponse> {
return userTokensResponseStore.get(userWalletId = key).filterNotNull()
}
}

View file

@ -1,15 +1,13 @@
package com.tangem.data.tokens
import arrow.core.left
import arrow.core.right
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.ExpressServiceFetcher
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.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.test.core.assertEither
import com.tangem.test.core.assertEitherRight
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -25,13 +23,11 @@ internal class AccountListCryptoCurrenciesFetcherTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true)
private val expressServiceFetcher: ExpressServiceFetcher = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val fetcher = AccountListCryptoCurrenciesFetcher(
userWalletsListRepository = userWalletsListRepository,
walletAccountsFetcher = walletAccountsFetcher,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
)
@ -43,7 +39,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
@Test
fun `returns failure if wallet is not multi-currency`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns false
@ -68,7 +64,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
@Test
fun `returns accounts if wallet is multi-currency`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
@ -79,7 +75,6 @@ internal class AccountListCryptoCurrenciesFetcherTest {
every { userWalletsListRepository.userWallets } returns userWalletsFlow
coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns response
coEvery { expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } returns Unit.right()
// Act
val actual = fetcher(params)
@ -90,14 +85,13 @@ internal class AccountListCryptoCurrenciesFetcherTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsListRepository.userWallets
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet())
}
}
@Test
fun `returns error if walletAccountsFetcher returns error`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.tokensync"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.tokensync.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultTokenSyncStore(
private val persistenceStore: DataStore<List<UserTokensResponse.Token>>,
) : TokenSyncStore {
override suspend fun get(): List<UserTokensResponse.Token> {
return persistenceStore.data.firstOrNull().orEmpty()
}
override suspend fun append(tokens: List<UserTokensResponse.Token>) {
persistenceStore.updateData { existing ->
(existing + tokens).distinct()
}
}
override suspend fun clear() {
persistenceStore.updateData { emptyList() }
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.data.tokensync.store
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
interface TokenSyncStore {
suspend fun get(): List<UserTokensResponse.Token>
suspend fun append(tokens: List<UserTokensResponse.Token>)
suspend fun clear()
}

View file

@ -0,0 +1,56 @@
package com.tangem.data.tokensync.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenSyncStoreFactory @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
@ApplicationContext private val context: Context,
private val appScope: AppCoroutineScope,
) {
private val stores = ConcurrentHashMap<String, TokenSyncStore>()
fun provide(userWalletId: UserWalletId): TokenSyncStore {
val userWalletStringId = userWalletId.formatted()
return stores.computeIfAbsent(userWalletStringId) {
DefaultTokenSyncStore(
persistenceStore = createPersistenceStore(
fileName = "token_sync_$userWalletStringId",
types = listTypes<UserTokensResponse.Token>(),
defaultValue = emptyList(),
),
)
}
}
private fun <T> createPersistenceStore(
fileName: String,
types: java.lang.reflect.ParameterizedType,
defaultValue: T,
): DataStore<T> = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = types,
defaultValue = defaultValue,
),
produceFile = { context.dataStoreFile(fileName = fileName) },
scope = appScope,
)
private fun UserWalletId.formatted(): String = stringValue.lowercase()
}

View file

@ -2,14 +2,16 @@ package com.tangem.data.pay
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.TangemPayEligibilityType
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.sync.Mutex
@ -17,57 +19,59 @@ import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
internal class DefaultTangemPayEligibilityManager @Inject constructor(
dispatchers: CoroutineDispatcherProvider,
private val userWalletsListRepository: UserWalletsListRepository,
private val coroutineScope: AppCoroutineScope,
private val onboardingRepository: OnboardingRepository,
) : TangemPayEligibilityManager {
private var cachedEligibleWallets: List<UserWalletData>? = null
private var cachedEligibleWallets: Map<TangemPayEntryPoint?, List<UserWalletData>> = emptyMap()
private var eligibleWalletsDeferred: Deferred<List<UserWalletData>>? = null
private val loadMutex = Mutex()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
init {
resetDataWhenWalletsUpdate()
}
override suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List<UserWallet> {
return getUserWalletsData().mapNotNull {
override suspend fun getEligibleWallets(
shouldExcludePaeraCustomers: Boolean,
entryPoint: TangemPayEntryPoint?,
): List<UserWallet> {
return getUserWalletsData(entryPoint = entryPoint).mapNotNull {
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet else null
}
}
override suspend fun getPossibleWalletsIds(shouldExcludePaeraCustomers: Boolean): List<UserWalletId> {
return getPossibleWalletsForTangemPay().addPaeraCustomersData().mapNotNull {
return getPossibleWalletsForTangemPay(entryPoint = null).addPaeraCustomersData().mapNotNull {
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet.walletId else null
}
}
override suspend fun getTangemPayAvailability(): Boolean {
return onboardingRepository.checkCustomerEligibility()
override suspend fun getTangemPayAvailability(entryPoint: TangemPayEntryPoint): Boolean {
val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty {
onboardingRepository.checkCustomerEligibility()
}
val type = entryPoint.toEligibilityType()
return eligibility.any { it == type }
.also { isEligible -> if (!isEligible) reset() }
}
override suspend fun isPaeraCustomerForAnyWallet(): Boolean {
return getUserWalletsData().any { it.isPaeraCustomer }
override suspend fun isPaeraCustomerForAnyWallet(entryPoint: TangemPayEntryPoint): Boolean {
return getUserWalletsData(entryPoint = entryPoint).any { it.isPaeraCustomer }
}
private suspend fun getUserWalletsData(): List<UserWalletData> {
cachedEligibleWallets?.let { return it }
private suspend fun getUserWalletsData(entryPoint: TangemPayEntryPoint?): List<UserWalletData> {
cachedEligibleWallets[entryPoint]?.let { return it }
return loadMutex.withLock {
cachedEligibleWallets?.let { return it }
cachedEligibleWallets[entryPoint]?.let { return it }
eligibleWalletsDeferred?.let { return it.await() }
coroutineScope {
val deferred = async {
if (!checkTangemPayEligibility()) {
emptyList()
} else {
getPossibleWalletsForTangemPay()
.addPaeraCustomersData()
.also { cachedEligibleWallets = it }
}
getPossibleWalletsForTangemPay(entryPoint = entryPoint)
.addPaeraCustomersData()
.also { cachedEligibleWallets += mapOf(entryPoint to it) }
}
eligibleWalletsDeferred = deferred
try {
@ -79,8 +83,8 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
}
}
private suspend fun getPossibleWalletsForTangemPay(): List<UserWallet> {
if (!checkTangemPayEligibility()) {
private suspend fun getPossibleWalletsForTangemPay(entryPoint: TangemPayEntryPoint?): List<UserWallet> {
if (!checkTangemPayEligibility(entryPoint = entryPoint)) {
return emptyList()
}
@ -122,13 +126,25 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
}
override fun reset() {
cachedEligibleWallets = null
cachedEligibleWallets = emptyMap()
eligibleWalletsDeferred?.cancel()
eligibleWalletsDeferred = null
}
private suspend fun checkTangemPayEligibility(): Boolean {
return onboardingRepository.getCustomerEligibility() || onboardingRepository.checkCustomerEligibility()
private suspend fun checkTangemPayEligibility(entryPoint: TangemPayEntryPoint?): Boolean {
val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty {
onboardingRepository.checkCustomerEligibility()
}
return if (entryPoint == null) {
eligibility.isNotEmpty()
} else {
eligibility.any { it == entryPoint.toEligibilityType() }
}
}
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
}
private data class UserWalletData(

View file

@ -18,6 +18,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -38,8 +39,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -112,6 +111,7 @@ internal interface TangemPayDataModule {
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
@ -122,9 +122,9 @@ internal interface TangemPayDataModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = scope,
),
dispatchers = dispatchers,
scope = scope,
)
}

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.TangemPayEligibilityType
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -211,19 +212,20 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun checkCustomerEligibility(): Boolean {
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> {
val response = requestHelper.performWithStaticToken {
tangemPayApi.checkCustomerEligibility()
tangemPayApi.getEligibilityChannels()
}.getOrNull()
val isAvailable = response?.result?.isTangemPayAvailable == true
tangemPayStorage.storeTangemPayEligibility(eligibility = isAvailable)
val channels = response?.result?.channels
val eligibility = channels.orEmpty().toSet()
tangemPayStorage.storeTangemPayEligibility(eligibility = eligibility)
return isAvailable
return eligibility.map(TangemPayEligibilityType::fromString)
}
override suspend fun getCustomerEligibility(): Boolean {
return tangemPayStorage.getTangemPayEligibility()
override suspend fun getCustomerEligibility(): List<TangemPayEligibilityType> {
return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString)
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {

View file

@ -19,6 +19,7 @@ import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
@ -45,9 +46,9 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
private val storage: TangemPayStorage,
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
private val errorConverter: TangemPayErrorConverter,
private val pollingScope: AppCoroutineScope,
) : TangemPayCardDetailsRepository {
private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val pollingJobs = mutableMapOf<String, Job>()
private val storePollingMutex = Mutex()

View file

@ -22,11 +22,9 @@ import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.extensions.addHexPrefix
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -43,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds
private const val TAG = "TangemPaySwapRepository"
private const val MAX_POLLING_ATTEMPTS = 6
private data class PollingKey(val userWalletId: String, val orderId: String)
@Suppress("LongParameterList")
@ -54,9 +53,9 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
private val tangemPayStorage: TangemPayStorage,
private val swapRepository: SwapRepository,
private val orderRepository: CustomerOrderRepository,
private val withdrawPollingScope: AppCoroutineScope,
) : TangemPayWithdrawRepository {
private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val pollingJobs = mutableMapOf<PollingKey, Job>()
private val pollingMutex = Mutex()
@ -139,7 +138,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
orderId: String,
exchangeData: TangemPayWithdrawExchangeState,
) {
pollingScope.launch {
withdrawPollingScope.launch {
startWithdrawOrderPolling(userWallet, orderId, exchangeData)
}
}
@ -154,7 +153,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
pollingMutex.withLock {
if (pollingJobs.containsKey(key)) return@withLock
val pollingJob = pollingScope.launch {
val pollingJob = withdrawPollingScope.launch {
try {
var attemptCount = 0
while (isActive && attemptCount < MAX_POLLING_ATTEMPTS) {
@ -194,7 +193,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
}
private fun stopPolling(userWalletId: String, orderId: String) {
pollingScope.launch {
withdrawPollingScope.launch {
pollingMutex.withLock {
val key = PollingKey(userWalletId, orderId)
pollingJobs[key]?.cancel()

View file

@ -4,11 +4,9 @@ import androidx.datastore.core.DataStore
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
@ -28,11 +26,9 @@ internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatu
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
try {

View file

@ -17,7 +17,6 @@ import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.data.walletconnect.utils.WcScope
import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
@ -33,6 +32,7 @@ import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -79,8 +79,8 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun sdkDelegate(wcScope: WcScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate(
scope = wcScope,
fun sdkDelegate(appScope: AppCoroutineScope, store: WalletConnectStore): WcPairSdkDelegate = WcPairSdkDelegate(
scope = appScope,
store = store,
)
@ -93,7 +93,7 @@ internal object WalletConnectDataModule {
wcNetworksConverter: WcNetworksConverter,
multiAccountListSupplier: MultiAccountListSupplier,
analytics: AnalyticsEventHandler,
wcScope: WcScope,
appScope: AppCoroutineScope,
): DefaultWcSessionsManager {
return DefaultWcSessionsManager(
store = store,
@ -101,7 +101,7 @@ internal object WalletConnectDataModule {
getWallets = getWallets,
wcNetworksConverter = wcNetworksConverter,
analytics = analytics,
scope = wcScope,
scope = appScope,
multiAccountListSupplier = multiAccountListSupplier,
)
}
@ -110,10 +110,6 @@ internal object WalletConnectDataModule {
@Singleton
fun wcSessionsManager(default: DefaultWcSessionsManager): WcSessionsManager = default
@Provides
@Singleton
fun wcScope(dispatchers: CoroutineDispatcherProvider): WcScope = WcScope(dispatchers)
@Provides
@Singleton
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default

View file

@ -1,5 +1,6 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.getOrElse
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.SimulationResult
import com.domain.blockaid.models.transaction.simultation.ApproveInfo
@ -17,16 +18,17 @@ import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.common.extensions.hexToBytes
import com.tangem.data.common.currency.getCoinId
import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCryptoCurrency
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.walletconnect.model.WcApprovedAmount
import com.tangem.domain.walletconnect.model.WcEthTransactionParams
import javax.inject.Inject
internal class WcEthTxHelper @Inject constructor(
private val getSingleCryptoCurrency: GetSingleCryptoCurrencyStatusUseCase,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val ethSpecificFee: GetEthSpecificFeeUseCase,
) {
@ -34,10 +36,19 @@ internal class WcEthTxHelper @Inject constructor(
val gasLimit = txParams.gas?.hexToBigInteger() ?: return null
val gasPrice = txParams.gasPrice?.hexToBigInteger()
val coinId = getCoinId(network, network.toBlockchain().toCoinId())
val currency = getSingleCryptoCurrency.invokeMultiWalletSync(userWallet.walletId, coinId)
.map { it.currency }
.getOrNull() ?: return null
return ethSpecificFee(userWallet, currency, gasLimit, gasPrice)
val currency = singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId)
.getCryptoCurrency(currencyId = coinId, network = network)
.getOrElse {
return null
}
return ethSpecificFee(
userWallet = userWallet,
cryptoCurrency = currency,
gasLimit = gasLimit,
gasPrice = gasPrice,
)
.map { it.minimum }
.getOrNull()
}

View file

@ -41,13 +41,17 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
@Suppress("LongMethod")
override operator fun invoke(): Flow<WcPairState> {
val (uri: String, source: WcPairRequest.Source) = pairRequest
return flow {
Timber.tag(WC_TAG).i("start pair flow $pairRequest")
analytics.send(WcAnalyticEvents.NewPairInitiated(source))
analytics.send(
WcAnalyticEvents.NewPairInitiated(
source = pairRequest.source,
screen = pairRequest.screen,
),
)
emit(WcPairState.Loading)
val pairResult = sdkDelegate.pair(uri)
val pairResult = sdkDelegate.pair(pairRequest.uri)
.onLeft {
Timber.tag(WC_TAG).e(it, "Failed to call pair $pairRequest")
analytics.send(

View file

@ -6,13 +6,13 @@ import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.WcScope
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
@ -22,7 +22,7 @@ import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
internal class WcPairSdkDelegate(
private val scope: WcScope,
private val scope: AppCoroutineScope,
private val store: WalletConnectStore,
) : WcSdkObserver {

View file

@ -8,7 +8,6 @@ import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.data.walletconnect.utils.WcScope
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.datasource.local.walletconnect.WalletConnectStore
@ -22,6 +21,7 @@ import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
@ -40,7 +40,7 @@ internal class DefaultWcSessionsManager(
private val dispatchers: CoroutineDispatcherProvider,
private val wcNetworksConverter: WcNetworksConverter,
private val analytics: AnalyticsEventHandler,
private val scope: WcScope,
private val scope: AppCoroutineScope,
) : WcSessionsManager, WcSdkObserver {
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)

View file

@ -1,13 +0,0 @@
package com.tangem.data.walletconnect.utils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext
internal class WcScope(
dispatchers: CoroutineDispatcherProvider,
) : CoroutineScope {
override val coroutineContext: CoroutineContext = SupervisorJob() + dispatchers.io
}

View file

@ -18,6 +18,7 @@ import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.blockchain.tokenbalance.models.TokenBalance
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchainsdk.BlockchainSDKFactory
@ -769,6 +770,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
return blockchain.getNFTExploreUrl(assetIdentifier)
}
override suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List<TokenBalance> {
val blockchain = network.toBlockchain()
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return emptyList()
val address = walletManager.wallet.address
return walletManager.getTokenBalances(address)
}
override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true

View file

@ -66,6 +66,11 @@ internal class SdkTransactionHistoryItemConverter(
is SdkTransactionHistoryItem.TransactionType.ContractMethodName,
-> mapToInteractionAddressType(destinationType = destinationType)
is SdkTransactionHistoryItem.TransactionType.SolanaStakingTransactionType.Stake -> {
transactionType.validatorAddress?.let {
TxInfo.InteractionAddressType.Validator(address = it)
}
}
is SdkTransactionHistoryItem.TransactionType.TronStakingTransactionType.VoteWitnessContract -> {
TxInfo.InteractionAddressType.Validator(address = transactionType.validatorAddress)
}

View file

@ -42,6 +42,15 @@ internal class SdkTransactionTypeConverter(
is TransactionType.Transfer -> {
TxInfo.TransactionType.Transfer
}
is TransactionType.SolanaStakingTransactionType.Stake -> {
TxInfo.TransactionType.Staking.Stake
}
is TransactionType.SolanaStakingTransactionType.Unstake -> {
TxInfo.TransactionType.Staking.Unstake
}
is TransactionType.SolanaStakingTransactionType.Withdraw -> {
TxInfo.TransactionType.Staking.Withdraw
}
is TransactionType.TronStakingTransactionType.FreezeBalanceV2Contract -> {
TxInfo.TransactionType.Staking.Stake
}

View file

@ -362,7 +362,7 @@ internal class DefaultWalletsRepository(
override suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>) =
withContext(dispatchers.io) {
val associateApplicationIdWithWallets: suspend () -> ApiResponse<Unit> = {
tangemTechApi.associateApplicationIdWithWalletsV2(
tangemTechApi.associateApplicationIdWithWallets(
applicationId = applicationId,
body = AssociateApplicationIdWithWalletsBody(
walletIds = wallets.map { it.walletId.stringValue }.distinct(),

View file

@ -2,6 +2,7 @@ package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.repositories.LegacySettingsRepository
@ -11,10 +12,7 @@ import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -27,11 +25,9 @@ class DefaultHotWalletAccessor @Inject constructor(
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
private val legacySettingsRepository: LegacySettingsRepository,
dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) : HotWalletAccessor {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
@ -233,7 +229,6 @@ class DefaultHotWalletAccessor @Inject constructor(
this is TangemSdkError.AuthenticationLockout ||
this is TangemSdkError.AuthenticationUnavailable ||
this is TangemSdkError.AuthenticationAlreadyInProgress ||
this is TangemSdkError.AuthenticationNotInitialized ||
this is TangemSdkError.AuthenticationPermanentLockout
}

View file

@ -186,7 +186,7 @@ class DefaultWalletsRepositoryTest {
inner class AssociateWallets {
@Test
fun `should convert and send to API V2`() = runTest {
fun `should convert and send to associateApplicationIdWithWallets`() = runTest {
// Arrange
val applicationId = "test_app_id"
val wallet1Id = "1234567890abcdef"
@ -202,7 +202,7 @@ class DefaultWalletsRepositoryTest {
)
coEvery {
tangemTechApi.associateApplicationIdWithWalletsV2(eq(applicationId), any())
tangemTechApi.associateApplicationIdWithWallets(eq(applicationId), any())
} returns ApiResponse.Success(Unit)
// Act
@ -210,7 +210,7 @@ class DefaultWalletsRepositoryTest {
// Assert
coVerify(exactly = 1) {
tangemTechApi.associateApplicationIdWithWalletsV2(
tangemTechApi.associateApplicationIdWithWallets(
applicationId = eq(applicationId),
body = match { body ->
body.walletIds.size == 2 &&