Updated on 2026-08-14
This commit is contained in:
parent
e323c7cc4a
commit
2471b9c1a8
6 changed files with 317 additions and 37 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue