Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-03 18:33:57 +04:00
parent e323c7cc4a
commit 2471b9c1a8
6 changed files with 317 additions and 37 deletions

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.core.utils)
/* Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.card)
@ -31,6 +32,7 @@ dependencies {
/* Libs - SDK */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
/* DI */

View file

@ -0,0 +1,94 @@
package com.tangem.data.common.currency
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import timber.log.Timber
/**
* Enriches the [UserTokensResponse] with accountId values for tokens
*
[REDACTED_AUTHOR]
*/
object UserTokensResponseAccountIdEnricher {
/**
* Enriches the tokens in the given [UserTokensResponse] with accountId values
*
* @param userWalletId the ID of the user wallet
* @param response the [UserTokensResponse] containing tokens to be enriched
*/
operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
val hasUnassignedTokens = response.tokens.any { it.accountId == null }
if (!hasUnassignedTokens) return response
val enrichedTokens = response.tokens
.filter { it.accountId == null }
.groupByAccountIndex()
.mapKeysToAccountId(userWalletId)
.mapToEnrichedTokens()
if (enrichedTokens.isEmpty()) return response
return response.copy(
tokens = response.tokens.map { token ->
val enrichedToken = enrichedTokens.find { it == token }
enrichedToken ?: token
},
)
}
private fun List<UserTokensResponse.Token>.groupByAccountIndex(): Map<Long?, List<UserTokensResponse.Token>> {
return this
.groupBy { savedToken ->
val derivationPathValue = savedToken.derivationPath
if (derivationPathValue == null) {
Timber.e("Token $savedToken has no derivation path")
return@groupBy null
}
val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId)
if (blockchain == null) {
Timber.e("Token $savedToken has unknown networkId")
return@groupBy null
}
val accountNodeRecognizer = AccountNodeRecognizer(blockchain)
val accountIndex = accountNodeRecognizer.recognize(derivationPathValue)
if (accountIndex == null) {
Timber.e("Token $savedToken has unrecognized derivation path")
return@groupBy null
}
accountIndex
}
}
private fun Map<Long?, List<UserTokensResponse.Token>>.mapKeysToAccountId(
userWalletId: UserWalletId,
): Map<AccountId?, List<UserTokensResponse.Token>> {
return mapKeys { (accountIndex, _) ->
if (accountIndex == null) return@mapKeys null
val derivationIndex = DerivationIndex.invoke(value = accountIndex.toInt()).getOrElse {
Timber.e("Failed to parse derivation index from account index: $accountIndex")
return@mapKeys null
}
AccountId.forCryptoPortfolio(userWalletId, derivationIndex)
}
}
private fun Map<AccountId?, List<UserTokensResponse.Token>>.mapToEnrichedTokens(): List<UserTokensResponse.Token> {
return flatMap { (accountId, tokens) ->
if (accountId == null) return@flatMap emptyList()
tokens.map { it.copy(accountId = accountId.value) }
}
}
}

View file

@ -5,60 +5,85 @@ import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
class UserTokensSaver(
private val tangemTechApi: TangemTechApi,
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.io) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = compatibleUserTokensResponse,
)
} else {
compatibleUserTokensResponse
}
userTokensResponseStore.store(userWalletId = userWalletId, response = enrichedUserTokensResponse)
}
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
val enrichedUserTokensResponse = userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
)
store(userWalletId, enrichedUserTokensResponse, false)
push(userWalletId, enrichedUserTokensResponse, false)
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true)
store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
}
}
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.default) {
val updatedResponse = response
.applyCompatibility()
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse)
}
suspend fun push(
userWalletId: UserWalletId,
response: UserTokensResponse,
useEnricher: Boolean = true,
onFailSend: () -> Unit = {},
) = withContext(dispatchers.io) {
val enrichedUserTokensResponse = if (useEnricher) {
userTokensResponseAddressesEnricher(
userWalletId = userWalletId,
response = response,
) {
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher)
safeApiCall(
call = {
withContext(dispatchers.io) {
tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse)
.bind()
}
},
onError = { onFailSend() },
)
} else {
response
}
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedUserTokensResponse).bind() }) {
Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}")
onFailSend()
}
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}
private suspend fun UserTokensResponse.enrichIf(
userWalletId: UserWalletId,
condition: Boolean,
): UserTokensResponse {
if (!condition) return this
return this
.enrichByAddress(userWalletId = userWalletId)
.let {
if (accountsFeatureToggles.isFeatureEnabled) {
it.enrichByAccountId(userWalletId = userWalletId)
} else {
it
}
}
}
private suspend fun UserTokensResponse.enrichByAddress(userWalletId: UserWalletId): UserTokensResponse {
return addressesEnricher(userWalletId = userWalletId, response = this)
}
private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse {
return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this)
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.tangemTech.TangemTechApi
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.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.wallets.repository.WalletsRepository
@ -58,13 +59,15 @@ internal object DataCommonModule {
tangemTechApi: TangemTechApi,
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
enricher: UserTokensResponseAddressesEnricher,
addressesEnricher: UserTokensResponseAddressesEnricher,
accountsFeatureToggles: AccountsFeatureToggles,
): UserTokensSaver {
return UserTokensSaver(
tangemTechApi = tangemTechApi,
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
userTokensResponseAddressesEnricher = enricher,
addressesEnricher = addressesEnricher,
accountsFeatureToggles = accountsFeatureToggles,
)
}

View file

@ -0,0 +1,151 @@
package com.tangem.data.common.currency
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserTokensResponseAccountIdEnricherTest {
private val userWalletId = UserWalletId("011")
private val mockCryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val userTokensResponseFactory = UserTokensResponseFactory()
@Test
fun `enriches tokens with missing account ids`() {
// Arrange
val response = mockCryptoCurrencyFactory.ethereumAndStellar
.mapIndexed { index, currency ->
currency.toResponseToken(
accountId = null,
derivationPath = "m/44'/60'/$index'/0/0",
)
}
.toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = response.tokens
.mapIndexed { index, currency ->
currency.enrichWithAccountId(accountIndex = index)
}
.toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `does not modify tokens with existing account ids`() {
// Arrange
val response = mockCryptoCurrencyFactory.ethereumAndStellar
.mapIndexed { index, currency ->
currency.toResponseToken(derivationPath = "m/44'/60'/$index'/0/0")
.enrichWithAccountId(accountIndex = index)
}
.toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = response
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `skips tokens with invalid derivation paths`() {
// Arrange
val validDerivationPath = "m/44'/60'/0'/0/0"
val invalidDerivationPath = "invalid/path"
val tokenWithInvalidPath = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
derivationPath = invalidDerivationPath,
)
val tokenWithValidPath = mockCryptoCurrencyFactory.stellar.toResponseToken(
accountId = null,
derivationPath = validDerivationPath,
)
val response = listOf(tokenWithInvalidPath, tokenWithValidPath).toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = listOf(
tokenWithInvalidPath,
tokenWithValidPath.enrichWithAccountId(accountIndex = 0),
).toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `skips tokens with unknown network id`() {
// Arrange
val unknownNetworkId = "unknown"
val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId
val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
networkId = unknownNetworkId,
derivationPath = "m/44'/60'/0'/0/0",
accountId = null,
)
val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
networkId = validNetworkId,
derivationPath = "m/44'/60'/0'/0/0",
)
val response = listOf(tokenWithUnknownNetworkId, tokenWithValidNetworkId).toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = listOf(
tokenWithUnknownNetworkId,
tokenWithValidNetworkId.enrichWithAccountId(accountIndex = 0),
).toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
private fun CryptoCurrency.toResponseToken(
accountId: AccountId? = null,
networkId: String? = null,
derivationPath: String,
): UserTokensResponse.Token {
return userTokensResponseFactory.createResponseToken(this).copy(
networkId = networkId ?: network.rawId,
derivationPath = derivationPath,
accountId = accountId?.value,
)
}
private fun List<UserTokensResponse.Token>.toResponse(): UserTokensResponse {
return UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = this,
)
}
private fun UserTokensResponse.Token.enrichWithAccountId(accountIndex: Int): UserTokensResponse.Token {
val derivationIndex = DerivationIndex(value = accountIndex).getOrNull()!!
val accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex)
return copy(accountId = accountId.value)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -19,12 +20,16 @@ class UserTokensSaverTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
private val enricher: UserTokensResponseAddressesEnricher = mockk()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles> {
every { this@mockk.isFeatureEnabled } returns true
}
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
userTokensResponseStore = userTokensResponseStore,
userTokensResponseAddressesEnricher = enricher,
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
accountsFeatureToggles = accountsFeatureToggles,
)
@BeforeEach