Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-05 14:01:26 +03:00
commit 9aacdb2ed3
695 changed files with 22022 additions and 5116 deletions

View file

@ -15,9 +15,14 @@ tasks.withType<Test>().configureEach {
dependencies {
// region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// endregion
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.res)
api(projects.core.utils)
// endregion
@ -27,6 +32,7 @@ dependencies {
api(projects.domain.common)
api(projects.domain.models)
api(projects.domain.tokens)
api(projects.domain.wallets)
// endregion
// region Project - Data

View file

@ -7,21 +7,28 @@ import javax.inject.Inject
/**
* Container for converter factories related to accounts.
*
* @property accountsListCF factory for creating an account list converter
* @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter
* @property accountsListCF factory for creating an account list converter
* @property cryptoPortfolioCF factory for creating a crypto portfolio converter
* @property userWalletsStore store for accessing user wallet data
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val accountsListCF: AccountListConverter.Factory,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userWalletsStore: UserWalletsStore,
) {
fun createWalletAccountsResponseConverter(userWalletId: UserWalletId): GetWalletAccountsResponseConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return getWalletAccountsResponseCF.create(userWallet)
}
fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)

View file

@ -29,7 +29,7 @@ internal class AccountListConverter @AssistedInject constructor(
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWalletId = userWallet.walletId,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
accounts = value.accounts.map(cryptoPortfolioConverter::convert),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),
groupType = TokensGroupTypeConverter.convert(value.wallet.group),

View file

@ -36,6 +36,7 @@ internal class CryptoPortfolioConverter @AssistedInject constructor(
responseCryptoCurrenciesFactory.createCurrencies(
tokens = tokens,
userWallet = userWallet,
accountIndex = value.derivationIndex.toDerivationIndex(),
).toSet()
} else {
emptySet()

View file

@ -1,5 +1,6 @@
package com.tangem.data.account.di
import android.content.Context
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles
@ -10,9 +11,9 @@ import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
@ -21,6 +22,7 @@ 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 javax.inject.Singleton
@ -42,8 +44,8 @@ internal object AccountDataModule {
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
userTokensSaver: UserTokensSaver,
eTagsStore: ETagsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): AccountsCRUDRepository {
return DefaultAccountsCRUDRepository(
@ -53,8 +55,9 @@ internal object AccountDataModule {
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
archivedAccountsETagStore = RuntimeStateStore(emptyMap()),
convertersContainer = accountConverterFactoryContainer,
resources = context.resources,
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.account.di
import com.tangem.data.account.producer.DefaultSingleAccountProducer
import com.tangem.domain.account.producer.SingleAccountProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SingleAccountProducerFactoryModule {
@Binds
@Singleton
fun bindSingleAccountProducerFactory(impl: DefaultSingleAccountProducer.Factory): SingleAccountProducer.Factory
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.account.di
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.supplier.SingleAccountSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object SingleAccountSupplierModule {
@Provides
@Singleton
fun provideSingleAccountSupplier(factory: SingleAccountProducer.Factory): SingleAccountSupplier {
return object : SingleAccountSupplier(
factory = factory,
keyCreator = { "single_account_${it.accountId.value}" },
) {}
}
}

View file

@ -4,7 +4,6 @@ import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.api.safeApiCall
@ -19,6 +18,7 @@ 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.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -51,18 +51,25 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) : WalletAccountsFetcher, WalletAccountsSaver {
override suspend fun fetch(userWalletId: UserWalletId) {
override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse {
val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse)
?: return
if (accountsResponse.accounts.isEmpty()) {
initializeAccounts(userWalletId, accountsResponse)
} else if (accountsResponse.unassignedTokens.isNotEmpty()) {
assignTokens(userWalletId, accountsResponse)
return when {
accountsResponse.accounts.isEmpty() -> {
initializeAccounts(userWalletId, accountsResponse)
}
accountsResponse.unassignedTokens.isNotEmpty() -> {
assignTokens(userWalletId, accountsResponse)
}
else -> accountsResponse
}
}
override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? {
return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
}
override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) {
val store = getAccountsResponseStore(userWalletId = userWalletId)
@ -115,7 +122,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private suspend fun fetchWalletAccounts(
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
): GetWalletAccountsResponse? {
): GetWalletAccountsResponse {
return safeApiCall(
call = {
val apiResponse = withContext(dispatchers.io) {
@ -145,7 +152,10 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
)
}
private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
private suspend fun initializeAccounts(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val response = defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = UserTokensResponse(
@ -156,14 +166,17 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
)
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts)
val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) ?: response
if (syncedResponse != null) {
store(userWalletId = userWalletId, response = syncedResponse)
}
store(userWalletId = userWalletId, response = syncedResponse)
return syncedResponse
}
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
private suspend fun assignTokens(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)
store(userWalletId = userWalletId, response = accountsResponseWithTokens)
@ -172,6 +185,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
userWalletId = userWalletId,
response = accountsResponseWithTokens.toUserTokensResponse(),
)
return accountsResponseWithTokens
}
private suspend fun getETag(userWalletId: UserWalletId): String? {

View file

@ -1,35 +1,50 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody
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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles errors that occur during the fetching of wallet accounts
*
* @property tangemTechApi API for network requests
* @property userTokensSaver saves user tokens to the storage
* @property userTokensResponseStore provides access to user token responses.
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
* @property eTagsStore store for ETags to manage caching
* @property dispatchers dispatchers
*
* @see DefaultWalletAccountsFetcher
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val userTokensSaver: UserTokensSaver,
private val userTokensResponseStore: UserTokensResponseStore,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
@ -49,23 +64,27 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?,
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): GetWalletAccountsResponse? {
): GetWalletAccountsResponse {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
if (isResponseUpToDate) {
Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
return savedAccountsResponse
return requireNotNull(savedAccountsResponse) {
"Saved accounts response is null for wallet: $userWalletId"
}
}
var response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse)
val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs)
val eTag = createWallet(userWalletId)
if (updatedResponse != null) {
response = updatedResponse
if (eTag != null) {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)
pushWalletAccounts(userWalletId, accountDTOs)
userTokensSaver.push(userWalletId, userTokensResponse)
}
}
@ -90,4 +109,24 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
}
.also { userTokensResponseStore.clear(userWalletId) }
}
/**
* Creates a wallet on the server and returns the ETag if successful.
*
* @param userWalletId The ID of the user wallet to create.
*/
private suspend fun createWallet(userWalletId: UserWalletId): String? {
val creationResponse = withContext(dispatchers.io) {
tangemTechApi.createWallet(
body = OnlyWalletIdBody(walletId = userWalletId.stringValue),
)
}
return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) {
creationResponse.headers[ETAG_HEADER]?.firstOrNull()
} else {
null
}
}
}

View file

@ -3,9 +3,9 @@ package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.some
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.userwallet.UserWalletsStore
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
@ -48,12 +48,16 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor(
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response.toUserTokensResponse(),
userWallet = userWallet,
).toSet()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty(),
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
}
.onEmpty { emit(emptySet()) }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}

View file

@ -0,0 +1,55 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.none
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleAccountProducer] that produces a flow of [Account.CryptoPortfolio]
* for a single account identified by [SingleAccountProducer.Params.accountId].
*
* It uses [SingleAccountListSupplier] to get the list of accounts and filters it to find the
* specific account.
*
* @property params Parameters containing the account ID for which the portfolio is produced.
* @property singleAccountListSupplier Supplier to get the list of accounts.
* @property dispatchers Coroutine dispatcher provider for managing threading.
*/
internal class DefaultSingleAccountProducer @AssistedInject constructor(
@Assisted val params: SingleAccountProducer.Params,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountProducer {
override val fallback: Option<Account.CryptoPortfolio>
get() = none()
override fun produce(): Flow<Account.CryptoPortfolio> {
return singleAccountListSupplier(
params = SingleAccountListProducer.Params(userWalletId = params.accountId.userWalletId),
)
.mapNotNull { accountList ->
accountList.accounts.firstOrNull {
it is Account.CryptoPortfolio && params.accountId == it.accountId
} as? Account.CryptoPortfolio
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : SingleAccountProducer.Factory {
override fun create(params: SingleAccountProducer.Params): DefaultSingleAccountProducer
}
}

View file

@ -1,21 +1,27 @@
package com.tangem.data.account.repository
import android.content.res.Resources
import arrow.core.Option
import arrow.core.raise.option
import arrow.core.toOption
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.core.res.getStringSafe
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.converter.ArchivedAccountConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.models.AccountList
@ -23,6 +29,7 @@ import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -43,8 +50,9 @@ internal class DefaultAccountsCRUDRepository(
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val userTokensSaver: UserTokensSaver,
private val eTagsStore: ETagsStore,
private val archivedAccountsETagStore: RuntimeStateStore<Map<String, String?>>,
private val convertersContainer: AccountConverterFactoryContainer,
private val resources: Resources,
private val dispatchers: CoroutineDispatcherProvider,
) : AccountsCRUDRepository {
@ -90,19 +98,49 @@ internal class DefaultAccountsCRUDRepository(
}
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) {
val response = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(
walletId = userWalletId.stringValue,
eTag = getETag(userWalletId),
).getOrThrow()
}
val eTag = archivedAccountsETagStore.getSyncOrNull()?.get(key = userWalletId.stringValue)
val store = getArchivedAccountsStore(userWalletId = userWalletId)
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
val response = safeApiCall(
call = {
val apiResponse = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
)
}
store.store(value = archivedAccounts)
saveETag(userWalletId, apiResponse)
apiResponse.bind()
},
onError = {
if (it is HttpException && it.code == HttpException.Code.NOT_MODIFIED) {
null
} else {
throw it
}
},
)
if (response != null) {
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
store.store(value = archivedAccounts)
}
}
override suspend fun saveAccountsLocally(accountList: AccountList) {
val converter = convertersContainer.createWalletAccountsResponseConverter(
userWalletId = accountList.userWalletId,
)
walletAccountsSaver.store(
userWalletId = accountList.userWalletId,
response = converter.convert(accountList),
)
}
override suspend fun saveAccounts(accountList: AccountList) {
@ -154,9 +192,17 @@ internal class DefaultAccountsCRUDRepository(
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>> {
override suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
return accountListResponse.accounts.size.toOption()
}
override fun getTotalActiveAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>> {
return getAccountsResponseStore(userWalletId = userWalletId).data
.map { it?.wallet?.totalAccounts.toOption() }
.map { it?.accounts?.size.toOption() }
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
@ -167,8 +213,25 @@ internal class DefaultAccountsCRUDRepository(
override fun getUserWalletsSync(): List<UserWallet> = userWalletsStore.userWalletsSync
private suspend fun getETag(userWalletId: UserWalletId): String? {
return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
override fun checkDefaultAccountName(accountList: AccountList, accountName: AccountName) {
val hasDefaultName = accountList.accounts.any { it.accountName is AccountName.DefaultMain }
if (!hasDefaultName) return
val defaultName = resources.getStringSafe(AccountNameUM.DefaultMain.stringResId)
.let(AccountName::invoke).getOrNull()
require(defaultName != accountName) {
"Cannot use default account name \"$accountName\" for custom accounts"
}
}
private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) {
val eTag = apiResponse.headers[ETAG_HEADER]?.firstOrNull()
archivedAccountsETagStore.update {
it + (userWalletId.stringValue to eTag)
}
}
private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? {

View file

@ -9,11 +9,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.UserTokensSaver
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.utils.getSyncOrNull
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.DerivationIndex
@ -78,6 +78,8 @@ internal class DefaultMainAccountTokensMigration(
},
)
store.updateData { updatedResponse }
userTokensSaver.push(
userWalletId = userWalletId,
response = updatedResponse.toUserTokensResponse(),

View file

@ -1,14 +1,16 @@
package com.tangem.data.account.utils
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.network.NetworkFactory
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.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
@ -16,22 +18,22 @@ import javax.inject.Inject
/**
* Factory to create default [GetWalletAccountsResponse].
*
* @property userWalletsListRepository repository to get user wallet information
* @property userWalletsStore store to get user wallet information
* @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts
* @property userTokensResponseFactory factory to create [UserTokensResponse]
* @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet
* @property networkFactory factory to create network derivation path
*
[REDACTED_AUTHOR]
*/
internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val userWalletsStore: UserWalletsStore,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val networkFactory: NetworkFactory,
) {
suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId }
fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId)
val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty()
val response = userTokensResponse.orDefault(userWallet = userWallet)
@ -59,10 +61,12 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private fun UserTokensResponse?.orDefault(userWallet: UserWallet?): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = userWallet?.let(cardCryptoCurrencyFactory::createDefaultCoinsForMultiCurrencyWallet).orEmpty(),
isGroupedByNetwork = false,
isSortedByBalance = false,
return userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = userWallet?.let {
AccountId.forCryptoPortfolio(userWalletId = it.walletId, derivationIndex = DerivationIndex.Main)
},
)
}
}

View file

@ -6,20 +6,6 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.wallet.UserWalletId
/** Flattens the tokens from all wallet accounts into a single list */
internal fun GetWalletAccountsResponse.flattenTokens(): List<UserTokensResponse.Token> {
return accounts.flatMap { it.tokens.orEmpty() }
}
/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */
internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse {
return UserTokensResponse(
group = wallet.group,
sort = wallet.sort,
tokens = flattenTokens(),
)
}
/**
* Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse]
*

View file

@ -77,7 +77,7 @@ internal fun createAccountList(
): AccountList {
return AccountList(
userWalletId = userWalletId,
accounts = setOf(createCryptoPortfolio(userWalletId)),
accounts = listOf(createCryptoPortfolio(userWalletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,

View file

@ -2,8 +2,6 @@ package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Test
@ -16,13 +14,7 @@ class SaveWalletAccountsResponseConverterTest {
fun convert() {
// Arrange
val userWalletId = UserWalletId("011")
val accountList = AccountList(
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
)
.getOrNull()!!
val accountList = createAccountList(userWalletId)
// Act
val actual = SaveWalletAccountsResponseConverter.convert(value = accountList)

View file

@ -29,6 +29,8 @@ class DefaultSingleAccountListFetcherTest {
// Arrange
val params = SingleAccountListFetcher.Params(userWalletId = userWalletId)
coEvery { walletAccountsFetcher.fetch(userWalletId) } returns mockk()
// Act
val actual = fetcher.invoke(params)

View file

@ -199,7 +199,7 @@ class DefaultWalletAccountsFetcherTest {
@Test
fun `fetch should call error handler when getWalletAccounts returns error`() = runTest {
// Arrange
val savedAccountsResponse = null
val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val apiError = ApiResponse.Error(ApiResponseError.NetworkException())
accountsResponseStoreFlow.value = savedAccountsResponse
@ -212,7 +212,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)
@ -231,7 +231,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)

View file

@ -3,15 +3,21 @@ 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.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody
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 com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -27,14 +33,19 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FetchWalletAccountsErrorHandlerTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
private val handler = FetchWalletAccountsErrorHandler(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
userTokensResponseStore = userTokensResponseStore,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse =
@ -53,6 +64,8 @@ class FetchWalletAccountsErrorHandlerTest {
@Test
fun `does not update accounts when response is up to date`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(userWalletId)
val error = ApiResponseError.HttpException(
code = Code.NOT_MODIFIED,
message = "Not Modified",
@ -63,7 +76,7 @@ class FetchWalletAccountsErrorHandlerTest {
handler.handle(
error = error,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = response,
pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts,
)
@ -91,6 +104,16 @@ class FetchWalletAccountsErrorHandlerTest {
val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val eTagValue = "etag-value"
val apiResponse = ApiResponse.Success(
data = Unit,
headers = mapOf(ETAG_HEADER to listOf(eTagValue)),
code = Code.CREATED,
)
coEvery {
tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue))
} returns apiResponse
coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse
// Act
@ -105,6 +128,8 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify {
userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue))
eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue)
pushWalletAccounts(userWalletId, listOf(accountDTO))
storeWalletAccounts(userWalletId, savedAccountsResponse)
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.account.repository
import android.content.res.Resources
import arrow.core.None
import arrow.core.toOption
import com.google.common.truth.Truth
@ -10,7 +11,6 @@ import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -54,12 +54,14 @@ class DefaultAccountsCRUDRepositoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensSaver: UserTokensSaver = mockk()
private val eTagsStore: ETagsStore = mockk()
private val archivedAccountsETagStore: RuntimeStateStore<Map<String, String?>> = mockk(relaxUnitFun = true)
private val convertersContainer: AccountConverterFactoryContainer = mockk()
private val accountListConverter: AccountListConverter = mockk()
private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk()
private val resources: Resources = mockk()
private val repository = DefaultAccountsCRUDRepository(
tangemTechApi = tangemTechApi,
walletAccountsSaver = walletAccountsSaver,
@ -67,8 +69,9 @@ class DefaultAccountsCRUDRepositoryTest {
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
archivedAccountsETagStore = archivedAccountsETagStore,
convertersContainer = convertersContainer,
resources = resources,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -532,7 +535,7 @@ class DefaultAccountsCRUDRepositoryTest {
val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO)
coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag
coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag)
coEvery {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
@ -546,9 +549,10 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(actual).containsExactly(archivedAccount)
coVerifyOrder {
eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts)
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
archivedAccountsETagStore.getSyncOrNull()
archivedAccountsStoreFactory.create(userWalletId)
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
archivedAccountsETagStore.update(any())
}
}
@ -558,7 +562,7 @@ class DefaultAccountsCRUDRepositoryTest {
val eTag = "etag123"
val exception = Exception("API error")
coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag
coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag)
coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } throws exception
// Act
@ -570,7 +574,7 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull()
coVerifyOrder {
eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts)
archivedAccountsETagStore.getSyncOrNull()
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
}
}
@ -644,15 +648,15 @@ class DefaultAccountsCRUDRepositoryTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCountSync {
inner class GetTotalActiveAccountsCountSync {
@Test
fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest {
fun `getTotalActiveAccountsCountSync returns None if account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
val actual = repository.getTotalActiveAccountsCountSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
@ -664,20 +668,22 @@ class DefaultAccountsCRUDRepositoryTest {
}
@Test
fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest {
fun `getTotalActiveAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest {
// Arrange
val totalAccounts = 5
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
val response = createGetWalletAccountsResponse(userWalletId).let {
it.copy(
wallet = it.wallet.copy(totalAccounts = totalAccounts),
)
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
val actual = repository.getTotalActiveAccountsCountSync(userWalletId)
// Assert
val expected = totalAccounts.toOption()
val expected = 1.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
@ -689,15 +695,15 @@ class DefaultAccountsCRUDRepositoryTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCount {
inner class GetTotalActiveAccountsCount {
@Test
fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest {
fun `getTotalActiveAccountsCount emits 0 when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val flow = repository.getTotalActiveAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
@ -710,21 +716,23 @@ class DefaultAccountsCRUDRepositoryTest {
}
@Test
fun `getTotalAccountsCount emits correct value when response is valid`() = runTest {
fun `getTotalActiveAccountsCount emits correct value when response is valid`() = runTest {
// Arrange
val totalAccounts = 7
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
val response = createGetWalletAccountsResponse(userWalletId).let {
it.copy(
wallet = it.wallet.copy(totalAccounts = totalAccounts),
)
}
accountsResponseStoreFlow.value = response
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val flow = repository.getTotalActiveAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
Truth.assertThat(actual).containsExactly(totalAccounts.toOption())
Truth.assertThat(actual).containsExactly(1.toOption())
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data

View file

@ -7,10 +7,10 @@ import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.UserTokensSaver
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.toUserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
@ -183,6 +183,8 @@ class DefaultMainAccountTokensMigrationTest {
accountsResponseStoreFlow.value = response
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
@ -199,6 +201,7 @@ class DefaultMainAccountTokensMigrationTest {
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
accountsResponseStore.updateData(any())
userTokensSaver.push(
userWalletId = userWalletId,
response = migratedResponse.toUserTokensResponse(),

View file

@ -3,12 +3,12 @@ package com.tangem.data.account.utils
import com.google.common.truth.Truth
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@ -23,17 +23,17 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultWalletAccountsResponseFactoryTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val userWalletsStore = mockk<UserWalletsStore>()
private val cryptoPortfolioCF = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory = mockk<UserTokensResponseFactory>()
private val cardCryptoCurrencyFactory = mockk<CardCryptoCurrencyFactory>()
private val networkFactory = mockk<NetworkFactory>()
private val factory = DefaultWalletAccountsResponseFactory(
userWalletsListRepository = userWalletsListRepository,
userWalletsStore = userWalletsStore,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
networkFactory = networkFactory,
)
private val userWalletId = UserWalletId("011")
@ -46,11 +46,11 @@ class DefaultWalletAccountsResponseFactoryTest {
@AfterEach
fun tearDownEach() {
clearMocks(
userWalletsListRepository,
userWalletsStore,
cryptoPortfolioCF,
cryptoPortfolioConverter,
userTokensResponseFactory,
cardCryptoCurrencyFactory,
networkFactory,
)
}
@ -63,12 +63,12 @@ class DefaultWalletAccountsResponseFactoryTest {
tokens = emptyList(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = null,
networkFactory = networkFactory,
accountId = null,
)
} returns userTokensResponse
@ -88,11 +88,11 @@ class DefaultWalletAccountsResponseFactoryTest {
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userWalletsStore.getSyncOrNull(userWalletId)
userTokensResponseFactory.createDefaultResponse(
userWallet = null,
networkFactory = networkFactory,
accountId = null,
)
}
}
@ -104,27 +104,24 @@ class DefaultWalletAccountsResponseFactoryTest {
every { walletId } returns userWalletId
}
val defaultCoins = listOf(mockk<CryptoCurrency.Coin>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
@ -145,13 +142,12 @@ class DefaultWalletAccountsResponseFactoryTest {
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userWalletsStore.getSyncOrNull(userWalletId)
cryptoPortfolioConverter.convertListBack(accounts)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
}
}
@ -162,22 +158,26 @@ class DefaultWalletAccountsResponseFactoryTest {
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
@ -203,7 +203,7 @@ class DefaultWalletAccountsResponseFactoryTest {
every { walletId } returns userWalletId
}
val assignedTokens = listOf(mockk<CryptoCurrency.Token>(), mockk<CryptoCurrency.Token>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,

View file

@ -4,6 +4,8 @@ import com.google.common.truth.Truth
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.flattenTokens
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.account
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -11,5 +12,8 @@ interface WalletAccountsFetcher {
/** Fetch wallet accounts by [userWalletId] */
@Throws
suspend fun fetch(userWalletId: UserWalletId)
suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse
/** Get saved wallet accounts by [userWalletId] */
suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse?
}

View file

@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.data.common.network.NetworkFactory
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,7 +19,7 @@ class CryptoCurrencyFactory(
private val excludedBlockchains: ExcludedBlockchains,
) {
private val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) }
val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) }
@Suppress("LongParameterList") // Yep, it's long
fun createToken(
@ -48,6 +49,7 @@ class CryptoCurrencyFactory(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Token? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
@ -58,6 +60,7 @@ class CryptoCurrencyFactory(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
val id = getTokenId(network, sdkToken)
@ -74,11 +77,16 @@ class CryptoCurrencyFactory(
)
}
fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? {
fun createCoin(
chainId: Int,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Coin? {
val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain
return if (blockchain != null) {
createCoin(blockchain, extraDerivationPath, userWallet)
createCoin(blockchain, extraDerivationPath, userWallet, accountIndex)
} else {
Timber.e("Unable to get blockchain from chainId == $chainId")
null
@ -89,6 +97,7 @@ class CryptoCurrencyFactory(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Coin? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
@ -99,6 +108,7 @@ class CryptoCurrencyFactory(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
return createCoin(network)

View file

@ -1,14 +1,16 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.tokens.getDefaultWalletBlockchains
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -23,10 +25,13 @@ import com.tangem.domain.models.wallet.isMultiCurrency
* @property userWalletsStore user wallets store
* @property userTokensResponseStore user tokens response store
*/
@Suppress("LongParameterList")
internal class DefaultCardCryptoCurrencyFactory(
private val demoConfig: DemoConfig,
private val excludedBlockchains: ExcludedBlockchains,
private val userWalletsStore: UserWalletsStore,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : CardCryptoCurrencyFactory {
@ -99,25 +104,7 @@ internal class DefaultCardCryptoCurrencyFactory(
override fun createDefaultCoinsForMultiCurrencyWallet(userWallet: UserWallet): List<CryptoCurrency.Coin> {
require(userWallet.isMultiCurrency) { "It isn't multi-currency wallet" }
val blockchains = when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.demoBlockchains
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() }
}
blockchainsInternal
}
is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
val blockchains = getDefaultWalletBlockchains(userWallet, demoConfig)
return blockchains.mapNotNull {
cryptoCurrencyFactory.createCoin(
@ -152,15 +139,32 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet,
networks: Set<Network>,
): Map<Network, List<CryptoCurrency>> {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
val existingNetworkWithCurrencies = if (accountsFeatureToggles.isFeatureEnabled) {
val response = walletAccountsFetcher.getSaved(userWallet.walletId)
?: return emptyMap()
val existingNetworkWithCurrencies = responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token ->
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
userWallet = userWallet,
)
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token ->
networks.any {
it.backendId == token.networkId && it.derivationPath.value == token.derivationPath
}
},
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
} else {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token ->
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
userWallet = userWallet,
)
}
.groupBy(CryptoCurrency::network)
return networks.associateWith { emptyList<CryptoCurrency>() } + existingNetworkWithCurrencies
@ -170,15 +174,28 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet,
rawIds: Set<Network.RawID>,
): Map<Network.RawID, List<CryptoCurrency>> {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
val networkIds = rawIds.map { it.toBlockchain().toNetworkId() }
return responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token -> token.networkId in networkIds },
userWallet = userWallet,
)
return if (accountsFeatureToggles.isFeatureEnabled) {
val response = walletAccountsFetcher.getSaved(userWallet.walletId)
?: return emptyMap()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token -> token.networkId in networkIds },
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
} else {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token -> token.networkId in networkIds },
userWallet = userWallet,
)
}
.groupBy { it.network.id.rawId }
}

View file

@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,26 +19,31 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
private val networkFactory: NetworkFactory,
) {
fun createCurrency(currencyId: String, response: UserTokensResponse, userWallet: UserWallet): CryptoCurrency {
return response.tokens
.asSequence()
.mapNotNull { createCurrency(it, userWallet) }
.first { it.id.value == currencyId }
fun createCurrencies(
response: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): List<CryptoCurrency> {
return createCurrencies(tokens = response.tokens, userWallet = userWallet, accountIndex = accountIndex)
}
fun createCurrencies(response: UserTokensResponse, userWallet: UserWallet): List<CryptoCurrency> {
return createCurrencies(tokens = response.tokens, userWallet = userWallet)
}
fun createCurrencies(tokens: List<UserTokensResponse.Token>, userWallet: UserWallet): List<CryptoCurrency> {
fun createCurrencies(
tokens: List<UserTokensResponse.Token>,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): List<CryptoCurrency> {
return tokens
.asSequence()
.mapNotNull { createCurrency(it, userWallet) }
.mapNotNull { createCurrency(it, userWallet, accountIndex) }
.distinctBy(CryptoCurrency::id)
.toList()
}
fun createCurrency(responseToken: UserTokensResponse.Token, userWallet: UserWallet): CryptoCurrency? {
fun createCurrency(
responseToken: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency? {
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
Timber.e("Unable to find a blockchain with the network ID: ${responseToken.networkId}")
@ -52,6 +58,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
blockchain = blockchain,
extraDerivationPath = responseToken.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
return createCurrency(responseToken = responseToken, userWallet = userWallet, network = network)

View file

@ -1,11 +1,17 @@
package com.tangem.data.common.currency
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.common.tokens.getDefaultWalletBlockchains
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import javax.inject.Inject
// TODO: [REDACTED_JIRA]
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
@ -43,4 +49,38 @@ class UserTokensResponseFactory @Inject constructor() {
)
}
}
fun createDefaultResponse(
userWallet: UserWallet?,
networkFactory: NetworkFactory,
accountId: AccountId?,
): UserTokensResponse {
val tokens = userWallet?.let {
getDefaultWalletBlockchains(userWallet = it, demoConfig = DemoConfig())
.map { blockchain ->
val derivationPath = networkFactory.createDerivationPath(
blockchain = blockchain,
extraDerivationPath = null,
cardDerivationStyleProvider = userWallet.derivationStyleProvider,
).value
UserTokensResponse.Token(
id = blockchain.toCoinId(),
accountId = accountId?.value,
networkId = blockchain.toNetworkId(),
derivationPath = derivationPath,
name = blockchain.getCoinName(),
symbol = blockchain.currency,
decimals = blockchain.decimals(),
contractAddress = null,
)
}
}
return UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = tokens.orEmpty(),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
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.*
@ -30,6 +31,8 @@ internal object DataCommonModule {
fun provideCardCryptoCurrencyFactory(
excludedBlockchains: ExcludedBlockchains,
userWalletsStore: UserWalletsStore,
accountsFeatureToggles: AccountsFeatureToggles,
walletAccountsFetcher: WalletAccountsFetcher,
userTokensResponseStore: UserTokensResponseStore,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
): CardCryptoCurrencyFactory {
@ -37,6 +40,8 @@ internal object DataCommonModule {
demoConfig = DemoConfig(),
excludedBlockchains = excludedBlockchains,
userWalletsStore = userWalletsStore,
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)

View file

@ -7,10 +7,12 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.wallets.derivations.derivationStyleProvider
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.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.lib.crypto.derivation.toMutable
import timber.log.Timber
import javax.inject.Inject
@ -21,6 +23,7 @@ import javax.inject.Inject
*
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
class NetworkFactory @Inject constructor(
private val excludedBlockchains: ExcludedBlockchains,
) {
@ -32,13 +35,19 @@ class NetworkFactory @Inject constructor(
* @param extraDerivationPath extra derivation path
* @param userWallet user wallet
*/
fun create(blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet): Network? {
fun create(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): Network? {
return create(
blockchain = blockchain,
derivationPath = createDerivationPath(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
cardDerivationStyleProvider = userWallet.derivationStyleProvider,
accountIndex = accountIndex,
),
canHandleTokens = userWallet.canHandleToken(
blockchain = blockchain,
@ -98,6 +107,7 @@ class NetworkFactory @Inject constructor(
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider?,
canHandleTokens: Boolean,
accountIndex: DerivationIndex? = null,
): Network? {
return create(
blockchain = blockchain,
@ -105,6 +115,7 @@ class NetworkFactory @Inject constructor(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
cardDerivationStyleProvider = derivationStyleProvider,
accountIndex = accountIndex,
),
canHandleTokens = canHandleTokens,
)
@ -148,14 +159,15 @@ class NetworkFactory @Inject constructor(
return true
}
private fun createDerivationPath(
fun createDerivationPath(
blockchain: Blockchain,
extraDerivationPath: String?,
cardDerivationStyleProvider: DerivationStyleProvider?,
accountIndex: DerivationIndex? = null,
): Network.DerivationPath {
if (cardDerivationStyleProvider == null) return Network.DerivationPath.None
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider)
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider, accountIndex)
return if (extraDerivationPath.isNullOrBlank()) {
if (defaultDerivationPath.isNullOrBlank()) {
@ -164,10 +176,11 @@ class NetworkFactory @Inject constructor(
Network.DerivationPath.Card(defaultDerivationPath)
}
} else {
if (extraDerivationPath == defaultDerivationPath) {
Network.DerivationPath.Card(defaultDerivationPath)
} else {
val isMainIndexOrNull = accountIndex == null || accountIndex == DerivationIndex.Main
if (extraDerivationPath != defaultDerivationPath && isMainIndexOrNull) {
Network.DerivationPath.Custom(extraDerivationPath)
} else {
Network.DerivationPath.Card(extraDerivationPath)
}
}
}
@ -175,8 +188,19 @@ class NetworkFactory @Inject constructor(
private fun getDefaultDerivationPath(
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
accountIndex: DerivationIndex?,
): String? {
return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
val default = blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())
?: return null
return if (accountIndex == null || accountIndex == DerivationIndex.Main) {
default
} else {
default.toMutable()
.replaceAccountNode(value = accountIndex.value.toLong(), blockchain = blockchain)
.apply()
}
.rawPath
}
private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType {
@ -344,8 +368,8 @@ class NetworkFactory @Inject constructor(
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
// Blockchain.Linea, Blockchain.LineaTestnet,
// Blockchain.ArbitrumNova,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
-> Network.TransactionExtrasType.NONE
// endregion
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.common.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.wallet.UserWallet
/**
* Returns the default blockchains for the multi-currency wallet.
*
* @param userWallet The user's wallet, which can be either a cold or hot wallet.
* @param demoConfig Configuration for demo cards, which may specify different default blockchains.
*/
fun getDefaultWalletBlockchains(userWallet: UserWallet, demoConfig: DemoConfig): Collection<Blockchain> {
return when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.demoBlockchains
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() }
}
blockchainsInternal
}
is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
}

View file

@ -9,10 +9,12 @@ import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.demo.models.DemoConfig
@ -37,6 +39,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk()
private val excludedBlockchains = ExcludedBlockchains()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles>()
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val factory = DefaultCardCryptoCurrencyFactory(
demoConfig = DemoConfig(),
@ -46,6 +50,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory(
networkFactory = NetworkFactory(excludedBlockchains = excludedBlockchains),
),
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
)
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
@ -57,7 +63,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
@BeforeEach
fun init() {
clearMocks(userWalletsStore, userTokensResponseStore, iconUri)
clearMocks(userWalletsStore, userTokensResponseStore, accountsFeatureToggles, walletAccountsFetcher, iconUri)
mockkStatic(Uri::class)
every { Uri.parse(any()) } returns iconUri
@ -75,6 +81,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
val userTokensResponse = model.userTokensResponse
val network = ethereum.network
every { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet
coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse
@ -236,6 +243,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
val networks = setOf(ethereum.network, bitcoin.network)
val userTokensResponse = model.userTokensResponse
every { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse
// Act

View file

@ -0,0 +1,113 @@
package com.tangem.data.express
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.data.express.converter.ExpressAssetConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
typealias InitializationStatusFlow = MutableStateFlow<Lce<Throwable, List<ExpressAsset>>>
/**
* Default implementation of [ExpressServiceFetcher]
*
* @property tangemExpressApi express api
* @property expressAssetsStore local storage
*
[REDACTED_AUTHOR]
*/
internal class DefaultExpressServiceFetcher @Inject constructor(
private val tangemExpressApi: TangemExpressApi,
private val expressAssetsStore: ExpressAssetsStore,
private val appPreferencesStore: AppPreferencesStore,
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ExpressServiceFetcher {
private val initializationStatuses =
MutableStateFlow<Map<UserWalletId, InitializationStatusFlow>>(value = emptyMap())
override suspend fun fetch(userWalletId: UserWalletId, assetIds: Set<ExpressAsset.ID>): Either<Throwable, Unit> =
either {
val userWallet = arrow.core.raise.catch(
block = { userWalletsStore.getSyncStrict(userWalletId) },
catch = ::raise,
)
fetch(userWallet = userWallet, assetIds = assetIds).bind()
}
override suspend fun fetch(userWallet: UserWallet, assetIds: Set<ExpressAsset.ID>): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.io) {
val initializationStatus = getInitializationStatusInternal(userWallet.walletId)
try {
if (assetIds.isNotEmpty()) {
val tokenList = assetIds.map {
LeastTokenInfo(contractAddress = it.contractAddress, network = it.networkId)
}
val response = tangemExpressApi.getAssets(
userWalletId = userWallet.walletId.stringValue,
refCode = getRefCode(userWallet, appPreferencesStore),
body = AssetsRequestBody(tokensList = tokenList),
).getOrThrow()
expressAssetsStore.store(userWallet.walletId, response)
val expressAssets = ExpressAssetConverter.convertList(response)
initializationStatus.update { expressAssets.lceContent() }
}
} catch (e: Throwable) {
if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) {
initializationStatus.update { e.lceError() }
}
Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}")
throw e
}
}
}
override fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<ExpressAsset>>> {
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value[userWalletId]
if (initializationStatus != null) return initializationStatus
val cached = expressAssetsStore.getSyncOrNull(userWalletId)?.let(ExpressAssetConverter::convertList)
val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading())
initializationStatuses.update { statuses ->
statuses.toMutableMap().apply {
put(key = userWalletId, value = default)
}
}
return default
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.express.converter
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.utils.converter.Converter
/**
* Converts an [Asset] from the data layer to an [ExpressAsset] in the domain layer.
*
[REDACTED_AUTHOR]
*/
internal object ExpressAssetConverter : Converter<Asset, ExpressAsset> {
override fun convert(value: Asset): ExpressAsset {
return ExpressAsset(
id = ExpressAsset.ID(
networkId = value.network,
contractAddress = value.contractAddress,
),
isExchangeAvailable = value.exchangeAvailable,
isOnrampAvailable = value.onrampAvailable,
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.express.di
import com.squareup.moshi.Moshi
import com.tangem.data.express.DefaultExpressRepository
import com.tangem.data.express.DefaultExpressServiceFetcher
import com.tangem.data.express.converter.ExpressErrorConverter
import com.tangem.data.express.error.DefaultExpressErrorResolver
import com.tangem.datasource.api.express.TangemExpressApi
@ -10,6 +11,7 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressErrorResolver
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -43,4 +45,10 @@ internal object ExpressDataModule {
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideExpressServiceFetcher(impl: DefaultExpressServiceFetcher): ExpressServiceFetcher {
return impl
}
}

View file

@ -15,6 +15,7 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)

View file

@ -238,7 +238,7 @@ internal class DefaultCustomTokensRepository(
"User tokens not found for user wallet [$userWalletId] while removing currency"
}
val token = userTokensResponseFactory.createResponseToken(cryptoCurrency)
val token = userTokensResponseFactory.createResponseToken(currency = cryptoCurrency, accountId = null)
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = storedCurrencies.copy(tokens = storedCurrencies.tokens.filterNot { it == token }),
@ -249,6 +249,27 @@ internal class DefaultCustomTokensRepository(
}
}
override suspend fun convertToCryptoCurrency(
userWalletId: UserWalletId,
currency: ManagedCryptoCurrency.Custom,
): CryptoCurrency {
return when (currency) {
is ManagedCryptoCurrency.Custom.Coin -> createCoin(
userWalletId = userWalletId,
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
)
is ManagedCryptoCurrency.Custom.Token -> cryptoCurrencyFactory.createToken(
network = currency.network,
rawId = currency.currencyId.rawCurrencyId,
name = currency.name,
symbol = currency.symbol,
decimals = currency.decimals,
contractAddress = currency.contractAddress,
)
}
}
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while getting supported networks"

View file

@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
@ -16,20 +17,20 @@ import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.card.common.extensions.canHandleBlockchain
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.extensions.supportedBlockchains
import com.tangem.domain.card.common.extensions.supportedTokens
import com.tangem.domain.card.common.extensions.*
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.managetokens.model.*
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
@ -40,7 +41,7 @@ import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher.Request
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultManageTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
@ -51,10 +52,16 @@ internal class DefaultManageTokensRepository(
private val excludedBlockchains: ExcludedBlockchains,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val dispatchers: CoroutineDispatcherProvider,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val accountsFeatureToggles: AccountsFeatureToggles,
networkFactory: NetworkFactory,
) : ManageTokensRepository {
private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(networkFactory, excludedBlockchains)
private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(
networkFactory = networkFactory,
excludedBlockchains = excludedBlockchains,
accountsFeatureToggles = accountsFeatureToggles,
)
private val userTokensResponseFactory = UserTokensResponseFactory()
// region getTokenListBatchFlow
@ -82,7 +89,10 @@ internal class DefaultManageTokensRepository(
val userWallet = request.params.userWalletId?.let(userWalletsStore::getSyncStrict)
if (userWallet is UserWallet.Cold && userWallet.scanResponse.card.isTestCard) {
fetchTestnetCurrencies(userWallet, request)
when (val params = request.params) {
is ManageTokensListConfig.Account -> fetchTestnetCurrencies(userWallet, params)
is ManageTokensListConfig.Wallet -> fetchTestnetCurrenciesLegacy(userWallet, params)
}
} else {
fetchCurrencies(
userWallet = userWallet,
@ -94,7 +104,6 @@ internal class DefaultManageTokensRepository(
},
)
@Suppress("ComplexCondition")
private suspend fun fetchCurrencies(
userWallet: UserWallet?,
request: Request<ManageTokensListConfig>,
@ -127,31 +136,22 @@ internal class DefaultManageTokensRepository(
coins = coinsResponse.coins.filterNot { l2BlockchainsCoinIds.contains(it.id) },
)
val tokensResponse = request.params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
// save tokens response only if loadUserTokensFromRemote is true and it means onboarding call
createAndSaveDefaultUserTokensResponse(userWallet = userWallet)
}
} else {
getSavedUserTokensResponseSync(userWalletId)
}
}
val items = if (isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
val items = when (val params = request.params) {
is ManageTokensListConfig.Account -> createManagedCryptoCurrencyList(
params = params,
userWallet = userWallet,
isFirstBatchFetching = isFirstBatchFetching,
loadUserTokensFromRemote = loadUserTokensFromRemote,
query = query,
updatedCoinsResponse = updatedCoinsResponse,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
is ManageTokensListConfig.Wallet -> createManagedCryptoCurrencyListLegacy(
params = params,
userWallet = userWallet,
isFirstBatchFetching = isFirstBatchFetching,
loadUserTokensFromRemote = loadUserTokensFromRemote,
query = query,
updatedCoinsResponse = updatedCoinsResponse,
)
}
@ -162,6 +162,112 @@ internal class DefaultManageTokensRepository(
)
}
private suspend fun createManagedCryptoCurrencyList(
params: ManageTokensListConfig.Account,
userWallet: UserWallet?,
isFirstBatchFetching: Boolean,
loadUserTokensFromRemote: Boolean,
query: String?,
updatedCoinsResponse: CoinsResponse,
): List<ManagedCryptoCurrency> {
val response = params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull()
} else {
walletAccountsFetcher.getSaved(userWalletId)
}
}
val accountId = when {
params.accountId == null -> null
loadUserTokensFromRemote -> {
AccountId.forCryptoPortfolio(
userWalletId = requireNotNull(params.accountId).userWalletId,
derivationIndex = DerivationIndex.Main,
)
}
else -> requireNotNull(params.accountId)
}
val accountDTO = if (response != null && accountId != null) {
response.accounts.firstOrNull { it.id == accountId.value }
} else {
null
}
val tokensResponse = response?.let {
UserTokensResponse(
group = response.wallet.group,
sort = response.wallet.sort,
tokens = accountDTO?.tokens.orEmpty(),
)
}
val isCreateWithCustom = isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
val items = if (isCreateWithCustom) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
}
return items
}
private suspend fun createManagedCryptoCurrencyListLegacy(
params: ManageTokensListConfig.Wallet,
userWallet: UserWallet?,
isFirstBatchFetching: Boolean,
loadUserTokensFromRemote: Boolean,
query: String?,
updatedCoinsResponse: CoinsResponse,
): List<ManagedCryptoCurrency> {
val tokensResponse = params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
// save tokens response only if loadUserTokensFromRemote is true and it means onboarding call
createAndSaveDefaultUserTokensResponse(userWallet = userWallet)
}
} else {
getSavedUserTokensResponseSync(userWalletId)
}
}
val isCreateWithCustom = isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
return if (isCreateWithCustom) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
)
}
}
private suspend fun createAndSaveDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse {
val userTokensResponse = createDefaultUserTokensResponse(userWallet)
userTokenSaver.store(userWallet.walletId, userTokensResponse, useEnricher = false)
@ -170,9 +276,59 @@ internal class DefaultManageTokensRepository(
private suspend fun fetchTestnetCurrencies(
userWallet: UserWallet,
request: Request<ManageTokensListConfig>,
params: ManageTokensListConfig.Account,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val searchText = request.params.searchText
val searchText = params.searchText
val testnetTokensConfig = testnetTokensStorage.getConfig()
val response = params.userWalletId?.let { userWalletId ->
walletAccountsFetcher.getSaved(userWalletId)
}
val accountId = params.accountId
val accountDTO = if (response != null && accountId != null) {
response.accounts.firstOrNull { it.id == accountId.value }
} else {
null
}
val tokensResponse = response?.let {
UserTokensResponse(
group = response.wallet.group,
sort = response.wallet.sort,
tokens = accountDTO?.tokens.orEmpty(),
)
}
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
testnetTokensConfig = if (!searchText.isNullOrBlank()) {
testnetTokensConfig.copy(
tokens = testnetTokensConfig.tokens.filter { token ->
token.symbol.contains(other = searchText, ignoreCase = true) ||
token.name.contains(other = searchText, ignoreCase = true)
},
)
} else {
testnetTokensConfig
},
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
return BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = true,
)
}
private suspend fun fetchTestnetCurrenciesLegacy(
userWallet: UserWallet,
params: ManageTokensListConfig.Wallet,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val searchText = params.searchText
val testnetTokensConfig = testnetTokensStorage.getConfig()
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
@ -188,6 +344,7 @@ internal class DefaultManageTokensRepository(
},
tokensResponse = getSavedUserTokensResponseSync(userWallet.walletId),
userWallet = userWallet,
accountIndex = null,
)
return BatchFetchResult.Success(

View file

@ -1,6 +1,7 @@
package com.tangem.data.managetokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.common.network.NetworkFactory
@ -11,6 +12,7 @@ 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.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,6 +40,8 @@ internal object ManageTokensDataModule {
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
networkFactory: NetworkFactory,
accountsFeatureToggles: AccountsFeatureToggles,
walletAccountsFetcher: WalletAccountsFetcher,
): ManageTokensRepository {
return DefaultManageTokensRepository(
tangemTechApi = tangemTechApi,
@ -50,6 +54,8 @@ internal object ManageTokensDataModule {
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
networkFactory = networkFactory,
dispatchers = dispatchers,
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
)
}

View file

@ -13,9 +13,11 @@ import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.models.TestnetTokensConfig
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -26,15 +28,17 @@ import timber.log.Timber
internal class ManagedCryptoCurrencyFactory(
private val networkFactory: NetworkFactory,
private val excludedBlockchains: ExcludedBlockchains,
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
fun create(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
return coinsResponse.coins.mapNotNull { coin ->
createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet)
createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex)
}
}
@ -42,9 +46,10 @@ internal class ManagedCryptoCurrencyFactory(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
val customTokens = createCustomTokens(tokensResponse, userWallet)
val tokens = create(coinsResponse, tokensResponse, userWallet)
val customTokens = createCustomTokens(tokensResponse, userWallet, accountIndex)
val tokens = create(coinsResponse, tokensResponse, userWallet, accountIndex)
return customTokens + tokens
}
@ -53,9 +58,10 @@ internal class ManagedCryptoCurrencyFactory(
testnetTokensConfig: TestnetTokensConfig,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
val customTokens = tokensResponse
?.let { createCustomTokens(it, userWallet) }
?.let { createCustomTokens(it, userWallet, accountIndex) }
?: emptyList()
val testnetTokens = testnetTokensConfig.tokens.map { testnetToken ->
ManagedCryptoCurrency.Token(
@ -69,9 +75,10 @@ internal class ManagedCryptoCurrencyFactory(
contractAddress = network.address,
decimals = network.decimalCount,
userWallet = userWallet,
accountIndex = accountIndex,
)
} ?: emptyList(),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex),
)
}
@ -81,28 +88,47 @@ internal class ManagedCryptoCurrencyFactory(
private fun createCustomTokens(
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> = tokensResponse.tokens
.mapNotNull { token ->
maybeCreateCustomToken(token, userWallet)
maybeCreateCustomToken(token, userWallet, accountIndex)
}
private fun maybeCreateCustomToken(
token: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): ManagedCryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(token.networkId)
?.takeUnless { it in excludedBlockchains }
?: return null
if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) {
return null
val network = if (accountsFeatureToggles.isFeatureEnabled) {
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
if (!checkIsCustomToken(token, network.derivationPath)) {
return null
}
network
} else {
if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) {
return null
}
networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
}
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
) ?: return null
val contractAddress = token.contractAddress
return if (contractAddress.isNullOrBlank()) {
@ -135,6 +161,7 @@ internal class ManagedCryptoCurrencyFactory(
tokensResponse: UserTokensResponse?,
imageHost: String?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): ManagedCryptoCurrency? {
if (coinResponse.networks.isEmpty() || !coinResponse.active) return null
@ -146,6 +173,7 @@ internal class ManagedCryptoCurrencyFactory(
contractAddress = network.contractAddress,
decimals = network.decimalCount?.toInt(),
userWallet = userWallet,
accountIndex = accountIndex,
)
}
.ifEmpty { return null }
@ -156,7 +184,7 @@ internal class ManagedCryptoCurrencyFactory(
symbol = coinResponse.symbol,
iconUrl = getIconUrl(coinResponse.id, imageHost),
availableNetworks = availableNetworks,
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet),
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex),
)
}
@ -166,6 +194,7 @@ internal class ManagedCryptoCurrencyFactory(
decimals: Int?,
userWallet: UserWallet?,
extraDerivationPath: String? = null,
accountIndex: DerivationIndex?,
): SourceNetwork? {
val blockchain = Blockchain.fromNetworkId(networkId)
?.takeUnless { it in excludedBlockchains }
@ -176,6 +205,7 @@ internal class ManagedCryptoCurrencyFactory(
extraDerivationPath = extraDerivationPath,
derivationStyleProvider = userWallet?.derivationStyleProvider,
canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) == true,
accountIndex = accountIndex,
) ?: return null
return when {
@ -205,6 +235,7 @@ internal class ManagedCryptoCurrencyFactory(
currencyId: String,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): Set<Network> {
if (tokensResponse == null) return emptySet()
@ -219,6 +250,7 @@ internal class ManagedCryptoCurrencyFactory(
extraDerivationPath = token.derivationPath,
derivationStyleProvider = userWallet?.derivationStyleProvider,
canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) != false,
accountIndex = accountIndex,
)
} else {
null
@ -237,6 +269,10 @@ internal class ManagedCryptoCurrencyFactory(
): Boolean = token.id.isNullOrBlank() ||
checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider)
private fun checkIsCustomToken(token: UserTokensResponse.Token, derivationPath: Network.DerivationPath): Boolean {
return token.id.isNullOrBlank() || derivationPath is Network.DerivationPath.Custom
}
private fun checkIsCustomDerivationPath(
derivationPath: String?,
blockchain: Blockchain,

View file

@ -22,6 +22,7 @@ import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.markets.*
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.pagination.*
@ -250,6 +251,7 @@ internal class DefaultMarketsTokenRepository(
userWalletId: UserWalletId,
token: TokenMarketParams,
network: TokenMarketInfo.Network,
accountIndex: DerivationIndex?,
): CryptoCurrency? {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found")
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: error("Unknown network [${network.networkId}]")
@ -259,12 +261,14 @@ internal class DefaultMarketsTokenRepository(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = accountIndex,
)
} else {
val currencyNetwork = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
cryptoCurrencyFactory.createToken(

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.models)
implementation(projects.domain.express.models)
// region DI

View file

@ -12,7 +12,6 @@ import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.TangemExpressValues
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
@ -38,6 +37,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -560,7 +560,7 @@ internal class DefaultOnrampRepository(
}
private fun CryptoCurrency.getContractAddress(): String = when (this) {
is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Coin -> ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Token -> this.contractAddress
}

View file

@ -44,6 +44,12 @@ internal class DefaultOnrampTransactionRepository(
}
}
override fun getAllTransactions(): Flow<List<OnrampTransaction>> {
return appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.map { transactions -> transactions.map(transactionConverter::convert) }
}
override suspend fun getTransactionById(txId: String): OnrampTransaction? = withContext(dispatchers.io) {
val stored = appPreferencesStore.getObjectSetSync<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,

View file

@ -75,6 +75,7 @@ internal class HotCryptoCurrencyConverter(
}
}
// TODO account
private fun createNetwork(networkId: String): Network? {
val blockchain = Blockchain.fromNetworkId(networkId) ?: return null

View file

@ -160,7 +160,7 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null
Blockchain.Quai, Blockchain.QuaiTestnet -> null
// Blockchain.Linea, Blockchain.LineaTestnet -> null
// Blockchain.ArbitrumNova -> null
Blockchain.Linea, Blockchain.LineaTestnet -> null
Blockchain.ArbitrumNova -> null
}
}

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.domain.networks)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
implementation(projects.domain.account)
/** Tangem SDK */
implementation(tangemDeps.blockchain) {

View file

@ -15,6 +15,9 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectList
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@ -27,6 +30,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
internal class DefaultSwapTransactionRepository(
@ -34,6 +38,8 @@ internal class DefaultSwapTransactionRepository(
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val networkFactory: NetworkFactory,
private val dispatchers: CoroutineDispatcherProvider,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val accountsFeatureToggles: AccountsFeatureToggles,
) : SwapTransactionRepository {
private val listConverter by lazy(LazyThreadSafetyMode.NONE) {
@ -54,13 +60,15 @@ internal class DefaultSwapTransactionRepository(
userWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
transaction: SwapTransactionModel,
) {
transaction.status?.let {
storeTransactionState(
txId = transaction.txId,
status = it,
accountWithCurrency = null,
accountWithCurrency = fromAccount?.accountId to fromCryptoCurrency,
)
}
appPreferencesStore.editData { mutablePreferences ->
@ -87,12 +95,16 @@ internal class DefaultSwapTransactionRepository(
userWalletId = userWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
toAccount = toAccount,
transactions = tokenTransactions,
) ?: listOf(
listConverter.default(
userWalletId = userWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
toAccount = toAccount,
tokenTransactions = listOf(converter.convert(transaction)),
),
),
@ -110,7 +122,12 @@ internal class DefaultSwapTransactionRepository(
flow2 = appPreferencesStore.getObjectMap<SwapStatusDTO>(
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
),
) { savedTransactions, txStatuses ->
flow3 = if (accountsFeatureToggles.isFeatureEnabled) {
multiAccountListSupplier()
} else {
flowOf(emptyList())
},
) { savedTransactions, txStatuses, multiAccountList ->
val currencyTxs = savedTransactions
?.filter {
it.userWalletId == userWallet.walletId.stringValue &&
@ -123,6 +140,7 @@ internal class DefaultSwapTransactionRepository(
currencyTxs?.mapNotNull {
listConverter.convertBack(
value = it,
multiAccountList = multiAccountList,
userWallet = userWallet,
txStatuses = txStatuses,
)
@ -221,10 +239,13 @@ internal class DefaultSwapTransactionRepository(
fromCryptoCurrencyId == fromCurrencyId.value
}
@Suppress("LongParameterList")
private fun List<SwapTransactionListDTO>.updateList(
userWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
transactions: List<SwapTransactionDTO>,
): List<SwapTransactionListDTO> {
return addOrReplace(
@ -232,6 +253,8 @@ internal class DefaultSwapTransactionRepository(
userWalletId = userWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
toAccount = toAccount,
tokenTransactions = transactions,
),
predicate = {

View file

@ -9,6 +9,9 @@ import com.tangem.data.swap.models.SwapStatusDTO
import com.tangem.data.swap.models.SwapTransactionDTO
import com.tangem.data.swap.models.SwapTransactionListDTO
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -32,11 +35,11 @@ internal class SavedSwapTransactionListConverter(
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromTokensResponse = userTokensResponseFactory.createResponseToken(
currency = value.fromCryptoCurrency,
accountId = null,
accountId = value.fromAccount?.accountId,
),
toTokensResponse = userTokensResponseFactory.createResponseToken(
currency = value.toCryptoCurrency,
accountId = null,
accountId = value.toAccount?.accountId,
),
transactions = savedSwapTransactionConverter.convertList(value.transactions),
)
@ -45,9 +48,12 @@ internal class SavedSwapTransactionListConverter(
value: SwapTransactionListDTO,
userWallet: UserWallet,
txStatuses: Map<String, SwapStatusDTO>,
multiAccountList: List<AccountList>,
): SwapTransactionListModel? {
val fromToken = value.fromTokensResponse
val toToken = value.toTokensResponse
val fromDerivationIndex = fromToken?.getDerivationIndex()
val toDerivationIndex = toToken?.getDerivationIndex()
return if (fromToken == null || toToken == null) {
null
} else {
@ -73,14 +79,25 @@ internal class SavedSwapTransactionListConverter(
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = getAccountByDerivationIndex(
multiAccountList = multiAccountList,
derivationIndex = fromDerivationIndex,
),
toAccount = getAccountByDerivationIndex(
multiAccountList = multiAccountList,
derivationIndex = toDerivationIndex,
),
)
}
}
@Suppress("LongParameterList")
fun default(
userWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
tokenTransactions: List<SwapTransactionDTO>,
) = SwapTransactionListDTO(
userWalletId = userWalletId.stringValue,
@ -88,9 +105,12 @@ internal class SavedSwapTransactionListConverter(
toCryptoCurrencyId = toCryptoCurrency.id.value,
fromTokensResponse = userTokensResponseFactory.createResponseToken(
currency = fromCryptoCurrency,
accountId = null,
accountId = fromAccount?.accountId,
),
toTokensResponse = userTokensResponseFactory.createResponseToken(
currency = toCryptoCurrency,
accountId = toAccount?.accountId,
),
toTokensResponse = userTokensResponseFactory.createResponseToken(currency = toCryptoCurrency, accountId = null),
transactions = tokenTransactions,
)
@ -108,7 +128,24 @@ internal class SavedSwapTransactionListConverter(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
accountIndex = token.accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() },
)
}
}
private fun getAccountByDerivationIndex(
multiAccountList: List<AccountList>,
derivationIndex: DerivationIndex?,
): Account.CryptoPortfolio? {
if (derivationIndex == null) return null
return multiAccountList.asSequence().firstNotNullOfOrNull { accountList ->
accountList.accounts.asSequence().filterIsInstance<Account.CryptoPortfolio>()
.firstOrNull { it.derivationIndex == derivationIndex }
}
}
private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? {
return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() }
}
}

View file

@ -12,6 +12,8 @@ import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
@ -68,12 +70,16 @@ internal object SwapDataModule {
appPreferencesStore: AppPreferencesStore,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
networkFactory: NetworkFactory,
multiAccountListSupplier: MultiAccountListSupplier,
accountsFeatureToggles: AccountsFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): SwapTransactionRepository {
return DefaultSwapTransactionRepository(
appPreferencesStore = appPreferencesStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
networkFactory = networkFactory,
multiAccountListSupplier = multiAccountListSupplier,
accountsFeatureToggles = accountsFeatureToggles,
dispatchers = dispatchers,
)
}

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.core)
implementation(projects.domain.demo)
implementation(projects.domain.express)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.staking)

View file

@ -1,9 +1,13 @@
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.datasource.local.userwallet.UserWalletsStore
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.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
@ -15,6 +19,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
*
* @property userWalletsStore [UserWallet]'s store
* @property walletAccountsFetcher instance of [WalletAccountsFetcher] to fetch accounts for a multi wallet
* @property expressServiceFetcher fetcher of express service
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
@ -22,14 +27,26 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
internal class AccountListCryptoCurrenciesFetcher(
private val userWalletsStore: UserWalletsStore,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val expressServiceFetcher: ExpressServiceFetcher,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher {
override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
override suspend fun invoke(params: Params): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.default) {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
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()
}
}
}

View file

@ -7,15 +7,14 @@ import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
@ -32,7 +31,7 @@ import timber.log.Timber
* @property userTokensResponseStore store of [UserTokensResponse]
* @property userTokensSaver user tokens saver
* @property cardCryptoCurrencyFactory factory for creating crypto currencies for specified card
* @property expressServiceLoader express service loader
* @property expressServiceFetcher express service loader
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
@ -46,7 +45,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher(
private val userTokensResponseStore: UserTokensResponseStore,
private val userTokensSaver: UserTokensSaver,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val expressServiceLoader: ExpressServiceLoader,
private val expressServiceFetcher: ExpressServiceFetcher,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher {
@ -109,14 +108,14 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher(
}
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
val tokens = userTokens.tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = token.networkId,
val tokens = userTokens.tokens.mapTo(hashSetOf()) { token ->
ExpressAsset.ID(
networkId = token.networkId,
contractAddress = token.contractAddress,
)
}
expressServiceLoader.update(userWallet = userWallet, userTokens = tokens)
expressServiceFetcher.fetch(userWallet = userWallet, assetIds = tokens)
}
private fun createDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse {
@ -124,6 +123,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcher(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet),
isGroupedByNetwork = false,
isSortedByBalance = false,
accountId = null,
)
}
}

View file

@ -7,11 +7,11 @@ import com.tangem.data.tokens.AccountListCryptoCurrenciesFetcher
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesFetcher
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -33,7 +33,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule {
userTokensResponseStore: UserTokensResponseStore,
userTokensSaver: UserTokensSaver,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
expressServiceLoader: ExpressServiceLoader,
expressServiceFetcher: ExpressServiceFetcher,
walletAccountsFetcher: WalletAccountsFetcher,
dispatchers: CoroutineDispatcherProvider,
): MultiWalletCryptoCurrenciesFetcher {
@ -41,6 +41,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule {
AccountListCryptoCurrenciesFetcher(
userWalletsStore = userWalletsStore,
walletAccountsFetcher = walletAccountsFetcher,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
)
} else {
@ -56,7 +57,7 @@ internal class MultiWalletCryptoCurrenciesFetcherModule {
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
)
}

View file

@ -5,20 +5,14 @@ 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.common.currency.UserTokensSaver
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository
import com.tangem.data.tokens.repository.DefaultYieldSupplyWarningsViewedRepository
import com.tangem.data.tokens.repository.*
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.TokenReceiveWarningActionStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.repository.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -40,7 +34,7 @@ internal object TokensDataModule {
walletManagersFacade: WalletManagersFacade,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
expressServiceLoader: ExpressServiceLoader,
expressServiceFetcher: ExpressServiceFetcher,
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
tokensSaver: UserTokensSaver,
@ -52,7 +46,7 @@ internal object TokensDataModule {
walletManagersFacade = walletManagersFacade,
cacheRegistry = cacheRegistry,
userTokensResponseStore = userTokensResponseStore,
expressServiceLoader = expressServiceLoader,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,

View file

@ -10,17 +10,16 @@ import com.tangem.data.common.currency.*
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -40,7 +39,7 @@ internal class DefaultCurrenciesRepository(
private val userWalletsStore: UserWalletsStore,
private val walletManagersFacade: WalletManagersFacade,
private val cacheRegistry: CacheRegistry,
private val expressServiceLoader: ExpressServiceLoader,
private val expressServiceFetcher: ExpressServiceFetcher,
private val dispatchers: CoroutineDispatcherProvider,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val userTokensSaver: UserTokensSaver,
@ -74,26 +73,6 @@ internal class DefaultCurrenciesRepository(
userTokensSaver.storeAndPush(userWalletId, response)
}
override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
withContext(dispatchers.io) {
val savedResponse = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action." },
)
val updatedResponse = savedResponse.copy(
tokens = currencies.map(userTokensResponseFactory::createResponseToken),
)
userTokensSaver.store(userWalletId = userWalletId, response = updatedResponse)
fetchExpressAssetsByNetworkIds(
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
userTokens = updatedResponse,
)
}
}
override suspend fun addCurrenciesCache(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
@ -322,50 +301,6 @@ internal class DefaultCurrenciesRepository(
)
}
override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) =
withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
val storedTokens = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWallet.walletId),
lazyMessage = {
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
},
)
responseCryptoCurrenciesFactory.createCurrencies(
storedTokens,
userWallet = userWallet,
)
}
override suspend fun getMultiCurrencyWalletCurrency(
userWalletId: UserWalletId,
id: CryptoCurrency.ID,
): CryptoCurrency = withContext(dispatchers.io) {
getMultiCurrencyWalletCurrency(userWalletId, id.value)
}
override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency =
withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
val response = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = {
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
},
)
responseCryptoCurrenciesFactory.createCurrency(
currencyId = id,
response = response,
userWallet = userWallet,
)
}
override suspend fun getNetworkCoin(
userWalletId: UserWalletId,
networkId: Network.ID,
@ -667,16 +602,17 @@ internal class DefaultCurrenciesRepository(
return demoConfig.isDemoCardId(userWallet.cardId) && response == null
}
// TODO [REDACTED_JIRA]
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
val tokens = userTokens.tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = token.networkId,
val tokens = userTokens.tokens.mapTo(hashSetOf()) { token ->
ExpressAsset.ID(
networkId = token.networkId,
contractAddress = token.contractAddress,
)
}
coroutineScope {
launch { expressServiceLoader.update(userWallet, tokens) }
launch { expressServiceFetcher.fetch(userWallet, tokens) }
}
}
@ -685,11 +621,11 @@ internal class DefaultCurrenciesRepository(
cryptoCurrencies: List<CryptoCurrency>,
refresh: Boolean = false,
) {
val tokens = cryptoCurrencies.map { currency ->
val tokens = cryptoCurrencies.mapTo(hashSetOf()) { currency ->
val tokenCurrency = currency as? CryptoCurrency.Token
LeastTokenInfo(
contractAddress = tokenCurrency?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = currency.network.backendId,
ExpressAsset.ID(
networkId = currency.network.backendId,
contractAddress = tokenCurrency?.contractAddress,
)
}
cacheRegistry.invokeOnExpire(
@ -697,7 +633,7 @@ internal class DefaultCurrenciesRepository(
skipCache = refresh,
block = {
coroutineScope {
launch { expressServiceLoader.update(userWallet, tokens) }
launch { expressServiceFetcher.fetch(userWallet, tokens) }
}
},
)
@ -731,6 +667,7 @@ internal class DefaultCurrenciesRepository(
),
isGroupedByNetwork = false,
isSortedByBalance = false,
accountId = null,
)
private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) {

View file

@ -1,10 +1,13 @@
package com.tangem.data.tokens
import arrow.core.left
import arrow.core.right
import com.tangem.common.test.utils.assertEither
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
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
@ -21,11 +24,13 @@ internal class AccountListCryptoCurrenciesFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxUnitFun = true)
private val expressServiceFetcher: ExpressServiceFetcher = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val fetcher = AccountListCryptoCurrenciesFetcher(
userWalletsStore = userWalletsStore,
walletAccountsFetcher = walletAccountsFetcher,
expressServiceFetcher = expressServiceFetcher,
dispatchers = dispatchers,
)
@ -59,8 +64,11 @@ internal class AccountListCryptoCurrenciesFetcherTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns true }
val response = mockk<GetWalletAccountsResponse>(relaxed = true)
every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet
coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns response
coEvery { expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet()) } returns Unit.right()
// Act
val actual = fetcher(params)
@ -71,6 +79,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.getSyncStrict(key = params.userWalletId)
walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = emptySet())
}
}

View file

@ -11,14 +11,13 @@ import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
@ -45,7 +44,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val expressServiceLoader: ExpressServiceLoader = mockk(relaxUnitFun = true)
private val expressServiceFetcher: ExpressServiceFetcher = mockk(relaxUnitFun = true)
private val fetcher = DefaultMultiWalletCryptoCurrenciesFetcher(
demoConfig = DemoConfig(),
@ -55,7 +54,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
expressServiceFetcher = expressServiceFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -67,7 +66,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensResponseStore,
userTokensSaver,
cardCryptoCurrencyFactory,
expressServiceLoader,
expressServiceFetcher,
)
}
@ -131,6 +130,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = userTokensResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -144,7 +150,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(mockUserWallet)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId())
}
}
@ -170,6 +176,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
} returns apiResponse.data
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = defaultResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -183,7 +196,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId())
}
coVerify(inverse = true) {
@ -212,6 +225,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
} returns apiResponse.data
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = apiResponse.data.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -224,7 +244,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId())
}
coVerify(inverse = true) {
@ -273,6 +293,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = userTokensResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -286,7 +313,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId())
}
coVerify(inverse = true) {
@ -317,6 +344,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
} returns defaultResponse
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = defaultResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -330,7 +364,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId())
}
coVerify(inverse = true) {
@ -383,6 +417,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = userTokensResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -398,7 +439,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensSaver.push(userWalletId = params.userWalletId, response = userTokensResponse)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = userTokensResponse.toAssetId())
}
}
@ -429,6 +470,13 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
} returns defaultResponse
coEvery {
expressServiceFetcher.fetch(
userWallet = mockUserWallet,
assetIds = defaultResponse.toAssetId(),
)
} returns Unit.right()
// Act
val actual = fetcher(params)
@ -443,7 +491,7 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
userTokensSaver.push(userWalletId = params.userWalletId, response = defaultResponse)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
expressServiceFetcher.fetch(userWallet = mockUserWallet, assetIds = defaultResponse.toAssetId())
}
coVerify(inverse = true) {
@ -471,11 +519,11 @@ internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
),
)
fun UserTokensResponse.toLeastTokens(): List<LeastTokenInfo> {
return tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = token.networkId,
fun UserTokensResponse.toAssetId(): Set<ExpressAsset.ID> {
return tokens.mapTo(hashSetOf()) { token ->
ExpressAsset.ID(
networkId = token.networkId,
contractAddress = token.contractAddress,
)
}
}

View file

@ -30,6 +30,7 @@ dependencies {
implementation(projects.domain.tokens)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)

View file

@ -1,66 +0,0 @@
package com.tangem.data.pay
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.ReceiveAddressModel.NameService
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.pay.DataForReceive
import com.tangem.domain.pay.DataForReceiveFactory
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
private const val TAG = "TangemPay: TokenReceiveConfigFactory"
internal class DefaultDataForReceiveFactory @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayWalletsManager: TangemPayWalletsManager,
excludedBlockchains: ExcludedBlockchains,
) : DataForReceiveFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) }
override fun getDataForReceive(depositAddress: String, chainId: Int): Either<UniversalError, DataForReceive> {
return try {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking()
/**
* Create [CryptoCurrency.Coin] only for F&F.
* Later will use [CryptoCurrency.Token] when contractAddresses will be provided by BFF.
*/
val currency = cryptoCurrencyFactory.createCoin(
chainId = chainId,
extraDerivationPath = null,
userWallet = wallet,
) ?: error("Cannot create crypto currency from chainId $chainId")
val result = DataForReceive(
currency = currency,
walletId = wallet.walletId,
receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)),
)
Either.Right(result)
} catch (exception: Exception) {
when (exception) {
is CancellationException -> {
throw exception
}
else -> {
Timber.tag(TAG).e(exception)
Either.Left(errorConverter.convert(exception))
}
}
}
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.data.pay
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.blockchain.blockchains.ethereum.Chain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.ReceiveAddressModel.NameService
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayTopUpData
import com.tangem.domain.pay.TangemPayTopUpDataFactory
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
private const val TAG = "TangemPay: DefaultDataForTopUpFactory"
/**
* Custom token parameters. Will be used only for F&F.
*/
private const val TOKEN_ID = "usd-coin"
private const val TOKEN_NAME = "USDC"
private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
private const val TOKEN_DECIMALS = 6
internal class DefaultTangemPayTopUpDataFactory @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayWalletsManager: TangemPayWalletsManager,
excludedBlockchains: ExcludedBlockchains,
) : TangemPayTopUpDataFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
private val networkFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
NetworkFactory(excludedBlockchains)
}
private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) }
private fun getCurrency(userWallet: UserWallet, chainId: Int): CryptoCurrency {
val chain = requireNotNull(Chain.entries.find { it.id == chainId }) { "Can not find chain with $chainId" }
val blockchain = requireNotNull(chain.blockchain)
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
return cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
)
}
override fun create(
depositAddress: String,
chainId: Int,
cryptoBalance: BigDecimal,
fiatBalance: BigDecimal,
): Either<UniversalError, TangemPayTopUpData> {
return try {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking()
val currency = getCurrency(wallet, chainId)
val result = TangemPayTopUpData(
currency = currency,
walletId = wallet.walletId,
cryptoBalance = cryptoBalance,
fiatBalance = fiatBalance,
depositAddress = depositAddress,
receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)),
)
Either.Right(result)
} catch (exception: Exception) {
when (exception) {
is CancellationException -> {
throw exception
}
else -> {
Timber.tag(TAG).e(exception)
Either.Left(errorConverter.convert(exception))
}
}
}
}
}

View file

@ -4,9 +4,8 @@ import arrow.core.Either
import arrow.core.raise.either
import com.tangem.common.CompletionResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject
@ -16,29 +15,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
) : TangemPayAuthDataSource {
override suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens> =
either {
val challenge = visaAuthRemoteDataSource
.getCustomerWalletAuthChallenge(address)
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
.bind()
override suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials> {
val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)
val signed = tangemSdkManager.visaCustomerWalletApprove(
VisaDataForApprove(
customerWalletCardId = cardId,
targetAddress = address,
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge),
),
).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind()
visaAuthRemoteDataSource.getTokenWithCustomerWallet(
sessionId = challenge.session.sessionId,
signature = signed.signature,
nonce = signed.dataToSign.hashToSign,
)
.mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") }
.bind()
return when (initialCredentials) {
is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error)
is CompletionResult.Success<TangemPayInitialCredentials> -> Either.Right(initialCredentials.data)
}
}
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(
@ -47,9 +31,4 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
.bind()
}
}
private fun <T> CompletionResult<T>.toEither(map: (Throwable) -> Throwable) = when (this) {
is CompletionResult.Success -> Either.Right(data)
is CompletionResult.Failure -> Either.Left(map(error))
}

View file

@ -1,16 +1,19 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultDataForReceiveFactory
import com.tangem.data.pay.DefaultTangemPayTopUpDataFactory
import com.tangem.data.pay.repository.DefaultCardDetailsRepository
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.DataForReceiveFactory
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.domain.pay.TangemPayTopUpDataFactory
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import dagger.Binds
import dagger.Module
@ -41,7 +44,13 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindDataForReceiveFactory(factory: DefaultDataForReceiveFactory): DataForReceiveFactory
fun bindDataForReceiveFactory(factory: DefaultTangemPayTopUpDataFactory): TangemPayTopUpDataFactory
@Binds
@Singleton
fun bindGetTangemPayCurrencyStatusUseCase(
impl: DefaultGetTangemPayCurrencyStatusUseCase,
): GetTangemPayCurrencyStatusUseCase
companion object {
@Provides
@ -52,6 +61,14 @@ internal interface TangemPayDataModule {
return TangemPayMainScreenCustomerInfoUseCase(repository = repository)
}
@Provides
@Singleton
fun provideProduceTangemPayInitialDataUseCase(
repository: OnboardingRepository,
): ProduceTangemPayInitialDataUseCase {
return ProduceTangemPayInitialDataUseCase(repository = repository)
}
@Provides
@Singleton
fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase {

View file

@ -0,0 +1,6 @@
package com.tangem.data.pay.entity
internal data class EncryptedData(
val encryptedBase64: String,
val ivBase64: String,
)

View file

@ -9,6 +9,9 @@ import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.CardDetailsRepository
@ -22,6 +25,7 @@ internal class DefaultCardDetailsRepository @Inject constructor(
private val visaLibLoader: VisaLibLoader,
private val apiConfigsManager: ApiConfigsManager,
private val rainCryptoUtil: RainCryptoUtil,
private val storage: TangemPayStorage,
) : CardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
@ -39,7 +43,7 @@ internal class DefaultCardDetailsRepository @Inject constructor(
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
tangemPayApi.revealCardDetails(
@ -51,14 +55,15 @@ internal class DefaultCardDetailsRepository @Inject constructor(
val pan = rainCryptoUtil.decryptSecret(
base64Secret = result.pan.secret,
base64Iv = result.pan.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
val cvv = rainCryptoUtil.decryptSecret(
base64Secret = result.cvv.secret,
base64Iv = result.cvv.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
secretKeyBytes.fill(0)
TangemPayCardDetails(
pan = pan,
@ -69,6 +74,44 @@ internal class DefaultCardDetailsRepository @Inject constructor(
}
}
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
secretKeyBytes.fill(0)
val status = requestHelper.request { authHeader ->
tangemPayApi.setPin(
authHeader = authHeader,
body = SetPinRequest(
sessionId = sessionId,
pin = encryptedData.encryptedBase64,
iv = encryptedData.ivBase64,
),
)
}.result?.result ?: error("Cannot set pin code")
when (status) {
SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS
SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK
SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR
else -> SetPinResult.UNKNOWN_ERROR
}
}
}
override suspend fun isAddToWalletDone(): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress())
}
}
override suspend fun setAddToWalletAsDone(): Either<UniversalError, Unit> {
return requestHelper.runWithErrorLogs(TAG) {
storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(), isDone = true)
}
}
private suspend fun getPublicKeyBase64(): String {
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
return when (env) {

View file

@ -2,11 +2,14 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
@ -26,17 +29,44 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
) : OnboardingRepository {
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request {
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
}.result
val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
.getOrThrow()
.result
result?.status == VALID_STATUS
}
}
override suspend fun isTangemPayInitialDataProduced(): Boolean {
val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId
val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false
return true
}
override suspend fun produceInitialData() {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId)
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = wallet.walletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
@ -84,7 +114,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun createOrder(): Either<UniversalError, Unit> = withContext(dispatcherProvider.io) {
requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val result = requestHelper.requestWithPersistedToken { authHeader ->
val result = requestHelper.request { authHeader ->
tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress))
}.result ?: error("Create order result is null")
@ -128,7 +158,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo {
val result = requestHelper.requestWithPersistedToken { authHeader ->
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
return getCustomerInfo(result)

View file

@ -1,8 +1,10 @@
package com.tangem.data.pay.repository
import com.squareup.moshi.Moshi
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
@ -26,8 +28,11 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val cacheRegistry: CacheRegistry,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
private val dispatchers: CoroutineDispatcherProvider,
@NetworkMoshi private val moshi: Moshi,
) : TangemPayTxHistoryRepository {
private val txHistoryItemConverter by lazy { TangemPayTxHistoryItemConverter(moshi) }
override fun getTxHistoryBatchFlow(
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
@ -84,7 +89,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
val result = requestPerformer.request { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
}.result
val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull()
val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull()
txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items)
}.onLeft { error(it.toString()) }
}

View file

@ -2,9 +2,7 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.error.UniversalError
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.ApiResponse
@ -14,10 +12,10 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.visa.model.getAuthHeader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@ -30,11 +28,9 @@ internal class TangemPayRequestPerformer @Inject constructor(
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val walletManagersFacade: WalletManagersFacade,
private val networkFactory: NetworkFactory,
) {
private var customerWalletAddress: String? = null
private val customerWalletAddress = MutableStateFlow<String?>(null)
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
@ -67,21 +63,14 @@ internal class TangemPayRequestPerformer @Inject constructor(
)
}
suspend fun <T : Any> requestWithPersistedToken(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = { getAccessTokensIfSaved() ?: error("Cannot get saved access tokens") },
refreshTokens = ::refreshAuthTokens,
)
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
refreshTokens: (suspend () -> VisaAuthTokens)? = null,
): T = runCatching {
requestBlock("Bearer ${getTokens().accessToken}").getOrThrow()
val tokens = getTokens()
val header = tokens.getAuthHeader()
requestBlock(header).getOrThrow()
}.getOrElse { error ->
val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED
if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) {
@ -116,52 +105,34 @@ internal class TangemPayRequestPerformer @Inject constructor(
return result
}
suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address
suspend fun getCustomerWalletAddress(): String {
val existingAddress = customerWalletAddress.value
if (existingAddress != null) {
return existingAddress
}
val storedAddress = tangemPayStorage.getCustomerWalletAddress(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId,
) ?: error("Can not find customer address")
customerWalletAddress.value = storedAddress
return storedAddress
}
private suspend fun getAccessTokens(): VisaAuthTokens {
return getAccessTokensIfSaved() ?: fetchTokens()
}
private suspend fun getAccessTokensIfSaved(): VisaAuthTokens? {
return tangemPayStorage.getAuthTokens(getCustomerWalletAddress())
}
private suspend fun fetchAuthInputData(): AuthInputData {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val network = networkFactory.create(
blockchain = Blockchain.Polygon,
extraDerivationPath = null,
derivationStyleProvider = wallet.derivationStyleProvider,
canHandleTokens = true,
) ?: error("Cannot create network")
val address = walletManagersFacade.getDefaultAddress(wallet.walletId, network)
?: error("Cannot get polygon address")
customerWalletAddress = address
return AuthInputData(address, wallet.cardId)
}
private suspend fun fetchTokens(): VisaAuthTokens {
val inputData = fetchAuthInputData()
val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId)
.getOrNull() ?: error("Cannot fetch tokens")
tangemPayStorage.storeAuthTokens(inputData.address, tokens)
val walletAddress = getCustomerWalletAddress()
val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored")
return tokens
}
private suspend fun refreshAuthTokens(): VisaAuthTokens {
val customerWalletAddress = getCustomerWalletAddress()
val refreshToken = getAccessTokens().refreshToken.value
val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() ?: error("Cannot refresh tokens")
val tokens = authDataSource.refreshAuthTokens(refreshToken)
.fold(
ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") },
ifRight = { it },
)
tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens)
return tokens
}
}
internal data class AuthInputData(
val address: String,
val cardId: String,
)
}

View file

@ -0,0 +1,62 @@
package com.tangem.data.pay.usecase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import java.math.BigDecimal
import javax.inject.Inject
internal class DefaultGetTangemPayCurrencyStatusUseCase @Inject constructor(
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
) : GetTangemPayCurrencyStatusUseCase {
override suspend fun invoke(
currency: CryptoCurrency,
cryptoAmount: BigDecimal,
fiatAmount: BigDecimal,
depositAddress: String,
): CryptoCurrencyStatus? {
val rawCurrencyId = currency.id.rawCurrencyId ?: return null
val quoteStatus = singleQuoteStatusSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId),
)?.value
val quoteStatusData: QuoteStatus.Data = when (quoteStatus) {
is QuoteStatus.Data -> quoteStatus
else -> {
singleQuoteStatusFetcher.invoke(
params = SingleQuoteStatusFetcher.Params(rawCurrencyId = rawCurrencyId, appCurrencyId = null),
)
singleQuoteStatusSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId),
)?.value as? QuoteStatus.Data
}
} ?: return null
return CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loaded(
amount = cryptoAmount,
fiatRate = quoteStatusData.fiatRate,
priceChange = quoteStatusData.priceChange,
fiatAmount = fiatAmount,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
yieldBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
),
)
}
}

View file

@ -1,11 +1,10 @@
package com.tangem.data.pay.util
import android.util.Base64
import com.tangem.utils.extensions.hexToBytes
import java.nio.charset.StandardCharsets
import java.security.KeyFactory
import java.security.SecureRandom
import com.tangem.common.extensions.toHexString
import com.tangem.data.pay.entity.EncryptedData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.security.spec.X509EncodedKeySpec
@ -21,26 +20,38 @@ private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
private const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
private const val AES_ALGORITHM = "AES"
private const val RSA_ALGORITHM = "RSA"
private const val IV_LENGTH_BYTES = 16
private const val PIN_LENGTH = 4
private const val PIN_LENGTH_BYTES = 8
private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2'
private const val PIN_BLOCK_FILL_CHAR = 'F'
internal class RainCryptoUtil @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<String, String> =
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<ByteArray, String> =
withContext(dispatchers.default) {
val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString()
val sessionId = generateSessionId(publicKeyBase64, secretKeyHex)
secretKeyHex to sessionId
val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes)
secretKeyBytes to sessionId
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String =
suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) {
val bytes = pinBlockByteArray(pin)
try {
encryptSecret(bytes, secretKeyBytes)
} finally {
bytes.clear()
}
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): String =
withContext(dispatchers.default) {
val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP)
if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short")
val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP)
val secretKeyBytes = secretKeyHex.hexToBytes()
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes)
@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor(
plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") }
}
private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String {
val secretKeyBytes = secretKeyHex.hexToBytes()
private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData {
val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv)
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec)
val ciphertext = cipher.doFinal(bytes)
bytes.clear()
return EncryptedData(
encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP),
ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP),
)
}
private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String {
val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP)
val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes)
val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec)
@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor(
return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP)
}
/**
* Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F].
* Example: 246784FFFFFFFFFF for PIN 6784.
*/
private fun pinBlockByteArray(pin: String): ByteArray {
require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" }
require(pin.all { it.isDigit() }) { "PIN must contain digits only" }
val pinBlockHexLength = PIN_LENGTH_BYTES * 2
val hex = buildString(pinBlockHexLength) {
append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX)
append(PIN_LENGTH.toString())
append(pin)
while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR)
}
return hex.toByteArray(StandardCharsets.UTF_8)
}
private fun ByteArray.clear() {
for (i in indices) this[i] = 0
}
}

View file

@ -1,18 +1,25 @@
package com.tangem.data.visa.utils
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
import timber.log.Timber
import java.util.Currency
internal object TangemPayTxHistoryItemConverter :
internal class TangemPayTxHistoryItemConverter(moshi: Moshi) :
Converter<TangemPayTxHistoryResponse.Transaction, TangemPayTxHistoryItem?> {
private val spendAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Spend::class.java) }
private val paymentAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Payment::class.java) }
private val feeAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Fee::class.java) }
private val collateralAdapter by lazy { moshi.adapter(TangemPayTxHistoryResponse.Collateral::class.java) }
override fun convert(value: TangemPayTxHistoryResponse.Transaction): TangemPayTxHistoryItem? {
return value.spend?.let { convertSpend(id = value.id, spend = it) }
?: value.payment?.let { convertPayment(id = value.id, payment = it) }
?: value.fee?.let { convertFee(id = value.id, fee = it) }
?: value.collateral?.let { convertCollateral(id = value.id, collateral = it) }
?: run {
Timber.wtf("unknown type of transaction: $value")
null
@ -22,8 +29,8 @@ internal object TangemPayTxHistoryItemConverter :
private fun convertSpend(id: String, spend: TangemPayTxHistoryResponse.Spend): TangemPayTxHistoryItem.Spend {
return TangemPayTxHistoryItem.Spend(
id = id,
// If postedAt is null, it means transaction wasn't posted and was likely declined. Use authorizedAt
date = spend.postedAt ?: spend.authorizedAt,
jsonRepresentation = spendAdapter.toJson(spend),
date = spend.authorizedAt,
amount = spend.amount,
currency = Currency.getInstance(spend.currency),
enrichedMerchantName = spend.enrichedMerchantName,
@ -41,18 +48,39 @@ internal object TangemPayTxHistoryItemConverter :
): TangemPayTxHistoryItem.Payment {
return TangemPayTxHistoryItem.Payment(
id = id,
jsonRepresentation = paymentAdapter.toJson(payment),
date = payment.postedAt,
currency = Currency.getInstance(payment.currency),
amount = payment.amount,
transactionHash = payment.transactionHash,
)
}
private fun convertFee(id: String, fee: TangemPayTxHistoryResponse.Fee): TangemPayTxHistoryItem.Fee {
return TangemPayTxHistoryItem.Fee(
id = id,
jsonRepresentation = feeAdapter.toJson(fee),
date = fee.postedAt,
currency = Currency.getInstance(fee.currency),
amount = fee.amount,
)
}
private fun convertCollateral(
id: String,
collateral: TangemPayTxHistoryResponse.Collateral,
): TangemPayTxHistoryItem.Collateral? {
val date = collateral.postedAt ?: return run {
Timber.e("Collateral transaction postedAt is null: $collateral")
return@run null
}
return TangemPayTxHistoryItem.Collateral(
id = id,
jsonRepresentation = collateralAdapter.toJson(collateral),
date = date,
currency = Currency.getInstance("usd"),
amount = collateral.amount,
transactionHash = collateral.transactionHash,
)
}
}

View file

@ -12,6 +12,8 @@ android {
dependencies {
/* Project - Domain */
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.transaction)

View file

@ -21,6 +21,10 @@ import com.tangem.data.walletconnect.utils.WcScope
import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
@ -92,6 +96,7 @@ internal object WalletConnectDataModule {
getWallets: GetWalletsUseCase,
wcNetworksConverter: WcNetworksConverter,
analytics: AnalyticsEventHandler,
accountsFeatureToggles: AccountsFeatureToggles,
wcScope: WcScope,
): DefaultWcSessionsManager {
return DefaultWcSessionsManager(
@ -100,6 +105,7 @@ internal object WalletConnectDataModule {
getWallets = getWallets,
wcNetworksConverter = wcNetworksConverter,
analytics = analytics,
accountsFeatureToggles = accountsFeatureToggles,
scope = wcScope,
)
}
@ -179,22 +185,26 @@ internal object WalletConnectDataModule {
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
walletManagersFacade: WalletManagersFacade,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
singleAccountSupplier: SingleAccountSupplier,
): WcNetworksConverter = WcNetworksConverter(
namespaceConverters = namespaceConverters,
walletManagersFacade = walletManagersFacade,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
singleAccountSupplier = singleAccountSupplier,
)
@Provides
@Singleton
fun associateNetworksDelegate(
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
networksConverter: WcNetworksConverter,
multiAccountListSupplier: MultiAccountListSupplier,
getWallets: GetWalletsUseCase,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = namespaceConverters,
getWallets = getWallets,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
networksConverter = networksConverter,
multiAccountListSupplier = multiAccountListSupplier,
)
@Provides

View file

@ -2,22 +2,25 @@ package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.Wallet.Model.Namespace
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
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.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
internal class AssociateNetworksDelegate(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val networksConverter: WcNetworksConverter,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val getWallets: GetWalletsUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
) {
@Throws(WcPairError.UnsupportedBlockchains::class)
@ -29,18 +32,48 @@ internal class AssociateNetworksDelegate(
.subtract(requiredNamespaces)
return userWallets.associateWith { wallet ->
mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces, sessionProposal)
mapNetworksForPortfolio(wallet, null, requiredNamespaces, optionalNamespaces, sessionProposal)
}
}
@Throws(WcPairError.UnsupportedBlockchains::class)
suspend fun associateAccounts(sessionProposal: Wallet.Model.SessionProposal): Map<AccountId, ProposalNetwork> {
val userWallets = getWallets.invokeSync()
.filter { it.isMultiCurrency && !it.isLocked }
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
// remove duplicates
.subtract(requiredNamespaces)
val allAccounts = multiAccountListSupplier.invoke()
.filter { it.isNotEmpty() }
.first()
.filter { accountList -> userWallets.any { accountList.userWalletId == it.walletId } }
.map { accountList -> accountList to userWallets.first { it.walletId == accountList.userWalletId } }
return allAccounts
.map { (accountList, wallet) -> accountList.accounts.map { account -> account to wallet } }
.flatten()
.associate { (account, wallet) ->
account.accountId to mapNetworksForPortfolio(
wallet = wallet,
account = account,
requiredNamespaces = requiredNamespaces,
optionalNamespaces = optionalNamespaces,
sessionProposal = sessionProposal,
)
}
}
@Suppress("ComplexCondition")
private suspend fun mapNetworksForWallet(
private suspend fun mapNetworksForPortfolio(
wallet: UserWallet,
account: Account?,
requiredNamespaces: Set<String>,
optionalNamespaces: Set<String>,
sessionProposal: Wallet.Model.SessionProposal,
): ProposalNetwork {
val walletNetworks = getWalletNetworks(userWalletId = wallet.walletId)
val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(userWalletId = wallet.walletId)
val unknownRequired = mutableSetOf<String>()
val unknownOptional = mutableSetOf<String>()
@ -50,28 +83,28 @@ internal class AssociateNetworksDelegate(
val notAdded = mutableSetOf<Network>()
requiredNamespaces.forEach { chainId ->
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
val wcNetwork = networksConverter.createNetwork(chainId, wallet)
if (wcNetwork == null) {
unknownRequired.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId }
val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (walletNetwork == null) {
if (portfolioNetwork == null) {
missingRequired.add(wcNetwork)
} else {
required.add(walletNetwork)
required.add(portfolioNetwork)
}
}
optionalNamespaces.forEach { chainId ->
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
val wcNetwork = networksConverter.createNetwork(chainId, wallet)
if (wcNetwork == null) {
unknownOptional.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (walletNetwork != null) {
available.add(walletNetwork)
val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (portfolioNetwork != null) {
available.add(portfolioNetwork)
} else {
notAdded.add(wcNetwork)
}
@ -88,16 +121,18 @@ internal class AssociateNetworksDelegate(
required = required,
available = available,
notAdded = notAdded,
account = account,
)
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>()
.map(CryptoCurrency.Coin::network)
return networksConverter.getWalletNetworks(userWalletId)
// flatten all derivation
.distinctBy { it.rawId }
}
private suspend fun getAccountNetworks(accountId: AccountId): List<Network> {
return networksConverter.getAccountNetworks(accountId)
// flatten all derivation
.distinctBy { it.rawId }
}

View file

@ -10,6 +10,7 @@ import com.reown.walletkit.client.Wallet
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.*
@ -34,6 +35,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
private val sdkDelegate: WcPairSdkDelegate,
private val blockAidVerifier: BlockAidVerifier,
private val analytics: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
@Assisted private val pairRequest: WcPairRequest,
) : WcPairUseCase {
@ -111,6 +113,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
val sessionDTO = WcSessionDTO(
topic = "",
walletId = sessionForApprove.wallet.walletId,
accountId = sessionForApprove.account?.accountId,
url = sdkVerifyContext.getDappOriginUrl(),
securityStatus = proposalState.dAppSession.securityStatus,
connectingTime = connectingTime,
@ -196,6 +199,11 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
verifyContext: Wallet.Model.VerifyContext,
): Either<WcPairError, WcPairState.Proposal> = runCatching {
val proposalNetwork = associateNetworksDelegate.associate(sessionProposal)
val proposalAccountNetwork = if (accountsFeatureToggles.isFeatureEnabled) {
associateNetworksDelegate.associateAccounts(sessionProposal)
} else {
null
}
val verificationInfo = when {
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
@ -204,7 +212,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
CheckDAppResult.FAILED_TO_VERIFY
}
}
val requestedNetworks = proposalNetwork
val requestedNetworks = (proposalAccountNetwork ?: proposalNetwork)
.values.map { it.available.plus(it.required) }.flatten().toSet()
analytics.send(
WcAnalyticEvents.PairRequested(
@ -225,6 +233,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
dAppMetaData = appMetaData,
proposalNetwork = proposalNetwork,
securityStatus = verificationInfo,
proposalAccountNetwork = proposalAccountNetwork,
)
WcPairState.Proposal(dAppSession)
}.fold(

View file

@ -8,6 +8,8 @@ import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.*
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcSession
@ -31,16 +33,20 @@ internal class DefaultWcSessionsManager(
private val dispatchers: CoroutineDispatcherProvider,
private val wcNetworksConverter: WcNetworksConverter,
private val analytics: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val scope: WcScope,
) : WcSessionsManager, WcSdkObserver {
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
private val oneTimeMigration = MutableStateFlow(false)
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
.transform { pair ->
val (wallets, inStore) = pair
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
val someMigrate = migrateToAccountSession(inStore)
if (someMigrate) return@transform
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions)
if (someRemove) return@transform // ignore emit, wait next one
@ -49,6 +55,26 @@ internal class DefaultWcSessionsManager(
.distinctUntilChanged()
.flowOn(dispatchers.io)
private suspend fun migrateToAccountSession(inStore: Set<WcSessionDTO>): Boolean {
if (!accountsFeatureToggles.isFeatureEnabled) return false
if (oneTimeMigration.value) return false
var someMigrated = false
val updatedSessions = inStore.mapTo(mutableSetOf()) { sessionDTO ->
if (sessionDTO.accountId == null) {
someMigrated = true
val mainAccountId = AccountId.forMainCryptoPortfolio(sessionDTO.walletId)
sessionDTO.copy(accountId = mainAccountId)
} else {
sessionDTO
}
}
if (someMigrated) store.saveSessions(updatedSessions)
oneTimeMigration.value = true
return someMigrated
}
override fun onWcSdkInit() {
listenOnSessionDelete()
extendSessions()
@ -90,7 +116,11 @@ internal class DefaultWcSessionsManager(
val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession ->
val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null
val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null
val networks = wcNetworksConverter.findWalletNetworks(wallet, sdkSession)
val account = storeSession.accountId?.let { wcNetworksConverter.getAccount(it) }
if (accountsFeatureToggles.isFeatureEnabled && account == null) {
return@mapNotNull null
}
val networks = wcNetworksConverter.findWalletNetworks(wallet, account, sdkSession)
val originUrl = storeSession.url ?: sdkSession.metaData?.url ?: ""
WcSession(
wallet = wallet,
@ -102,6 +132,7 @@ internal class DefaultWcSessionsManager(
),
securityStatus = storeSession.securityStatus,
networks = networks,
account = account,
connectingTime = storeSession.connectingTime,
showWalletInfo = wallets.size > 1,
)

View file

@ -3,6 +3,13 @@ package com.tangem.data.walletconnect.utils
import com.reown.walletkit.client.Wallet
import com.tangem.data.common.currency.isCustomCoin
import com.tangem.data.walletconnect.model.CAIP10
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,6 +25,8 @@ import javax.inject.Inject
internal class WcNetworksConverter @Inject constructor(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val walletManagersFacade: WalletManagersFacade,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val singleAccountSupplier: SingleAccountSupplier,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
) {
@ -67,8 +76,13 @@ internal class WcNetworksConverter @Inject constructor(
return allCoinNetwork
}
suspend fun findWalletNetworks(wallet: UserWallet, sdkSession: Wallet.Model.Session): Set<Network> {
val walletNetworks = getWalletNetworks(wallet.walletId)
suspend fun findWalletNetworks(
wallet: UserWallet,
account: Account?,
sdkSession: Wallet.Model.Session,
): Set<Network> {
val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(wallet.walletId)
val existNetworks = sdkSession.namespaces.values
.map { it.accounts }.flatten().toSet()
.mapNotNull { CAIP10.fromRaw(it) }
@ -76,7 +90,7 @@ internal class WcNetworksConverter @Inject constructor(
val blockchain = namespaceConverters
.firstNotNullOfOrNull { it.toBlockchain(caip10.chainId) }
?: return@mapNotNullTo null
walletNetworks
portfolioNetworks
// find all derivation
.filter { it.rawId == blockchain.id }
// find equal address
@ -89,18 +103,38 @@ internal class WcNetworksConverter @Inject constructor(
return existNetworks
}
suspend fun getAccount(accountId: AccountId): Account? {
return singleAccountSupplier.getSyncOrNull(SingleAccountProducer.Params(accountId))
}
suspend fun convertNetworksForApprove(sessionForApprove: WcSessionApprove): List<Network> {
val walletNetworks = getWalletNetworks(sessionForApprove.wallet.walletId)
val portfolioNetworks = sessionForApprove.account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(sessionForApprove.wallet.walletId)
return sessionForApprove.network
.map { network -> walletNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } }
.map { network -> portfolioNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } }
.flatten()
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>().map(CryptoCurrency.Coin::network)
}
private suspend fun getAccountStatus(accountId: AccountId): AccountStatus? {
return singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(accountId.userWalletId),
)?.accountStatuses?.find { it.account.accountId == accountId }
}
suspend fun getAccountNetworks(accountId: AccountId): List<Network> {
return getAccountStatus(accountId)
?.flattenCurrencies()
?.map { it.currency }
?.filterIsInstance<CryptoCurrency.Coin>()
?.map(CryptoCurrency.Coin::network)
?: emptyList()
}
}

View file

@ -14,9 +14,13 @@ import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletconnect.model.*
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import io.mockk.coEvery
import io.mockk.coVerifyOrder
@ -33,6 +37,7 @@ internal class DefaultWcPairUseCaseTest {
private val analytics: AnalyticsEventHandler = mockk<AnalyticsEventHandler>(relaxed = true)
private val sdkDelegate: WcPairSdkDelegate = mockk<WcPairSdkDelegate>()
private val blockAidVerifier: BlockAidVerifier = mockk<BlockAidVerifier>()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles>()
private val url = "testUrl"
private val source = WcPairRequest.Source.QR
@ -70,6 +75,7 @@ internal class DefaultWcPairUseCaseTest {
get() = WcSessionApprove(
wallet = MockUserWalletFactory.create(),
network = listOf(),
account = null,
)
private val sdkApprove: Wallet.Params.SessionApprove
@ -102,6 +108,7 @@ internal class DefaultWcPairUseCaseTest {
networks = setOf(),
connectingTime = null,
showWalletInfo = false,
account = null,
)
private fun useCaseFactory() = DefaultWcPairUseCase(
@ -110,12 +117,14 @@ internal class DefaultWcPairUseCaseTest {
sdkDelegate = sdkDelegate,
blockAidVerifier = blockAidVerifier,
analytics = analytics,
accountsFeatureToggles = accountsFeatureToggles,
pairRequest = WcPairRequest(userWalletId = UserWalletId(""), uri = url, source = source),
)
@Before
fun setup() {
coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf()
coEvery { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery {
caipNamespaceDelegate.associate(
sessionProposal = sdkProposal,

View file

@ -58,6 +58,7 @@ internal class WcSignUseCaseDelegateTest {
session = WcSession(
wallet = MockUserWalletFactory.create(),
networks = setOf(),
account = null,
securityStatus = CheckDAppResult.FAILED_TO_VERIFY,
connectingTime = 0L,
sdkModel = WcSdkSession(

View file

@ -10,20 +10,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.common.response.fold
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody
import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFICATION_SHOW_TIME
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -37,7 +30,6 @@ import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@ -52,7 +44,7 @@ internal class DefaultWalletsRepository(
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
MutableStateFlow(mutableSetOf<UserWalletId>())
MutableStateFlow(mutableSetOf())
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
@ -260,6 +252,14 @@ internal class DefaultWalletsRepository(
}
}
override suspend fun createWallet(userWalletId: UserWalletId) {
withContext(dispatchers.io) {
tangemTechApi.createWallet(
body = OnlyWalletIdBody(userWalletId.stringValue),
)
}
}
override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) {
runCatching(dispatchers.io) {
tangemTechApi.updateSeedPhraseSecondNotificationStatus(