Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-31 17:29:19 +04:00
parent d3dbc2c3d0
commit 1a4405fd28
5 changed files with 382 additions and 16 deletions

View file

@ -41,7 +41,9 @@ class UserTokensSaver(
return@withContext
}
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher)
val enrichedResponse = response
.withoutDuplicates()
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
push(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend)
}
@ -85,7 +87,46 @@ class UserTokensSaver(
apiResponse.bind()
}
},
onError = { onFailSend() },
onError = { error ->
TangemLogger.e("Failed to push ${response.tokens.size} user tokens", error)
onFailSend()
},
)
}
/**
* Drops fully identical tokens from the list.
*
* The API rejects the whole list with `400 All tokens's elements must be unique` if it contains two equal
* elements, so a single duplicate discards the update of the entire wallet's token list. Duplicates are not
* expected here, hence the error log.
*
* Tokens are compared by every field of the request instead of [UserTokensResponse.Token.equals], which
* deliberately ignores most of them: dropping an element that differs in any way would silently change what the
* user has saved.
*/
private fun UserTokensResponse.withoutDuplicates(): UserTokensResponse {
val uniqueTokens = tokens.distinctBy { it.toRequestKey() }
if (uniqueTokens.size == tokens.size) return this
TangemLogger.e("Dropped ${tokens.size - uniqueTokens.size} duplicated tokens before pushing them")
return copy(tokens = uniqueTokens)
}
private fun UserTokensResponse.Token.toRequestKey(): List<Any?> {
return listOf(
id,
accountId,
networkId,
derivationPath,
name,
symbol,
decimals,
contractAddress,
addresses,
dynamicAddressesEnabled,
)
}

View file

@ -95,4 +95,75 @@ class UserTokensSaverTest {
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
}
@Test
fun `GIVEN response with duplicated tokens WHEN push THEN duplicates are dropped before the api call`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns ""
}
val token = createToken()
val response = createResponse(tokens = listOf(token, token))
val uniqueResponse = createResponse(tokens = listOf(token))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
coEvery { enricher(userWalletId, uniqueResponse) } returns uniqueResponse
coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.push(userWalletId = userWalletId, response = response)
// THEN
coVerify(exactly = 1) { tangemTechApi.saveTokens(userWalletId.stringValue, uniqueResponse) }
}
@Test
fun `GIVEN tokens differing in account id only WHEN push THEN both of them are pushed`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns ""
}
val token = createToken()
val response = createResponse(tokens = listOf(token, token.copy(accountId = "other-account")))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
coEvery { enricher(userWalletId, response) } returns response
coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.push(userWalletId = userWalletId, response = response)
// THEN
coVerify(exactly = 1) { tangemTechApi.saveTokens(userWalletId.stringValue, response) }
}
private fun createResponse(tokens: List<UserTokensResponse.Token>): UserTokensResponse {
return UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = tokens,
walletName = null,
walletType = WalletType.COLD,
)
}
private fun createToken(): UserTokensResponse.Token {
return UserTokensResponse.Token(
id = "ethereum",
accountId = "account",
networkId = "ethereum",
derivationPath = "m/44'/60'/0'/0/1",
name = "Ethereum",
symbol = "ETH",
decimals = 18,
contractAddress = null,
)
}
}