diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index ece3e5297a..9994bc2f46 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -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 */ diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt new file mode 100644 index 0000000000..b52d3c3f86 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -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.groupByAccountIndex(): Map> { + 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>.mapKeysToAccountId( + userWalletId: UserWalletId, + ): Map> { + 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>.mapToEnrichedTokens(): List { + return flatMap { (accountId, tokens) -> + if (accountId == null) return@flatMap emptyList() + + tokens.map { it.copy(accountId = accountId.value) } + } + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 95c9010707..e0bfb846d8 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -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) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 85260e75da..9b18e1b8fd 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -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, ) } diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt new file mode 100644 index 0000000000..bd7c253d03 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricherTest.kt @@ -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.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) + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 6b4a086180..66e4d28c01 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -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 { + every { this@mockk.isFeatureEnabled } returns true + } private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, userTokensResponseStore = userTokensResponseStore, - userTokensResponseAddressesEnricher = enricher, dispatchers = TestingCoroutineDispatcherProvider(), + addressesEnricher = enricher, + accountsFeatureToggles = accountsFeatureToggles, ) @BeforeEach