Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 14:34:37 +01:00
commit b995f180f1
20 changed files with 139 additions and 37 deletions

View file

@ -46,6 +46,8 @@ internal class DefaultAddressBookRepository(
private val writeMutex = Mutex()
private val logger = TangemLogger.withTag(LOG_TAG)
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.onStart { syncAddressBooks() }
@ -124,6 +126,9 @@ internal class DefaultAddressBookRepository(
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
val wallets = userWalletsListRepository.userWalletsSync()
// Debug (Logcat only, dev builds) — the persisted prod log stays quiet on the happy path; only failures
// below are written at Error level.
logger.d("Syncing address books for ${wallets.size} wallet(s)")
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
// first failed chunk.
wallets.chunked(MAX_SYNC_WALLETS)
@ -145,22 +150,38 @@ internal class DefaultAddressBookRepository(
call = {
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
// Only wallets whose etag changed are returned; the rest keep their local copy.
logger.d("Sync response: ${response.items.size} updated book(s) out of ${wallets.size} requested")
response.items.forEach { item ->
val userWalletId = UserWalletId(stringValue = item.walletId)
// Metadata only — helps QA verify what the backend delivered (esp. for cross-platform books).
// Debug (Logcat only) to keep the persisted prod log free of happy-path sync noise.
logger.d(
"Storing synced address book for wallet ${item.walletId}: version=${item.version}, " +
"updatedAt=${item.updatedAt}, nonceLen=${item.nonce.length}, " +
"ciphertextLen=${item.ciphertext.length}, authTagLen=${item.authTag.length}",
)
blobStore.storeBlob(item.toBlob())
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
}
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to sync address books: $error")
logger.e("Failed to sync address books: $error")
error.toSyncError().left()
},
)
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
return cipher.decrypt(blob, userWallet).fold(
ifLeft = { error ->
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
// can see that a stored/synced book (e.g. one created on iOS) could not be shown to the user.
logger.e("Skipping address book for wallet ${blob.walletId}: decrypt failed with $error")
emptyList()
},
ifRight = { it.contacts },
)
}
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
@ -176,9 +197,7 @@ internal class DefaultAddressBookRepository(
val updatedAt = DateTime.parse(timestampProvider.now())
return cipher.encrypt(addressBook, userWallet, updatedAt)
.mapLeft { error ->
TangemLogger.e(
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
)
logger.e("Failed to encrypt address book for wallet ${userWallet.walletId}: $error")
AddressBookSyncError.Unknown
}
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
@ -209,7 +228,7 @@ internal class DefaultAddressBookRepository(
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
logger.e("Failed to push address book for wallet $userWalletId: $error")
val syncError = error.toSyncError()
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
@ -249,6 +268,7 @@ internal class DefaultAddressBookRepository(
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
private companion object {
const val LOG_TAG = "AddressBook"
const val MAX_SYNC_WALLETS = 20
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.extensions.hexToBytesOrNull
import com.tangem.utils.extensions.toHexString
import com.tangem.utils.logging.TangemLogger
import kotlinx.serialization.json.Json
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
@ -35,6 +36,7 @@ class AddressBookCipher {
private val json = Json { ignoreUnknownKeys = true }
private val secureRandom = SecureRandom()
private val logger = TangemLogger.withTag(LOG_TAG)
fun encrypt(
addressBook: AddressBook,
@ -58,24 +60,97 @@ class AddressBookCipher {
nonce = nonce.toHexString().lowercase(),
ciphertext = ciphertext.toHexString().lowercase(),
authTag = authTag.toHexString().lowercase(),
)
).also { blob ->
// Metadata only — the plaintext (contact names/addresses/memos) is never logged.
logger.d(
"Encrypted address book for wallet ${blob.walletId}: contacts=${addressBook.contacts.size}, " +
"plaintextBytes=${plaintext.size}, version=${blob.version}, " +
"nonceLen=${blob.nonce.length}, ciphertextLen=${blob.ciphertext.length}, " +
"authTagLen=${blob.authTag.length}",
)
}
}
fun decrypt(blob: AddressBookBlob, userWallet: UserWallet): Either<AddressBookCryptoError, AddressBook> = either {
ensure(blob.walletId == userWallet.walletId.stringValue) { AddressBookCryptoError.WalletMismatch }
// Metadata only — helps QA correlate a failing blob with what was received from the backend/other platform.
logger.d(
"Decrypting address book for wallet ${blob.walletId}: version=${blob.version}, " +
"updatedAt=${blob.updatedAt}, nonceLen=${blob.nonce.length}, " +
"ciphertextLen=${blob.ciphertext.length}, authTagLen=${blob.authTag.length}",
)
ensure(blob.walletId == userWallet.walletId.stringValue) {
logger.w(
"Wallet mismatch decrypting address book: blob wallet=${blob.walletId}, " +
"target wallet=${userWallet.walletId.stringValue}",
)
AddressBookCryptoError.WalletMismatch
}
val aesKey = deriveKey(userWallet)
val nonce = blob.nonce.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val authTag = blob.authTag.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val nonce = blob.nonce.hexToBytesOrNull() ?: raiseNonHex(blob, field = "nonce", value = blob.nonce)
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raiseNonHex(
blob = blob,
field = "ciphertext",
value = blob.ciphertext,
)
val authTag = blob.authTag.hexToBytesOrNull() ?: raiseNonHex(blob, field = "authTag", value = blob.authTag)
val plaintext = runCatching {
cipher(Cipher.DECRYPT_MODE, aesKey, nonce).doFinal(ciphertext + authTag)
}.getOrElse { raise(AddressBookCryptoError.DecryptionFailed) }
}.getOrElse { error ->
logger.e(
"Failed to AES-GCM decrypt address book for wallet ${blob.walletId} " +
"(nonceBytes=${nonce.size}, ciphertextBytes=${ciphertext.size}, authTagBytes=${authTag.size}): " +
"${error.safeDescription()}. Usually a wrong key/nonce/tag or a corrupted blob.",
error,
)
raise(AddressBookCryptoError.DecryptionFailed)
}
runCatching {
json.decodeFromString(AddressBook.serializer(), plaintext.toString(Charsets.UTF_8))
}.getOrElse { raise(AddressBookCryptoError.MalformedBlob) }
}.getOrElse { error ->
// Decryption succeeded, so this is a payload schema/format mismatch — the prime suspect for a book
// reason (missing/extra field, wrong type, JSON path) but strips any raw plaintext the exception echoes.
logger.e(
"Failed to parse decrypted address book for wallet ${blob.walletId} " +
"(plaintextBytes=${plaintext.size}, version=${blob.version}): ${error.safeDescription()}. " +
"Decryption OK → cross-platform payload schema/format mismatch.",
)
raise(AddressBookCryptoError.MalformedBlob)
}.also { addressBook ->
logger.d(
messageString = "Decrypted address book for wallet ${blob.walletId}: " +
"contacts=${addressBook.contacts.size}",
)
}
}
private fun Raise<AddressBookCryptoError>.raiseNonHex(
blob: AddressBookBlob,
field: String,
value: String,
): Nothing {
logger.e(
"Address book blob has non-hex $field for wallet ${blob.walletId} " +
"(${field}Len=${value.length}, version=${blob.version}).",
)
raise(AddressBookCryptoError.DecryptionFailed)
}
/**
* A log-safe, one-line description of a failure: exception type + message, with any raw decrypted payload
* stripped. kotlinx.serialization appends the offending input after a `JSON input:` marker; everything before
* it (the reason and the `at path: ` location) is structural and safe to log.
*/
private fun Throwable.safeDescription(): String {
val safeMessage = message.orEmpty().substringBefore("JSON input:").trim()
return buildString {
append(this@safeDescription::class.simpleName)
if (safeMessage.isNotEmpty()) append(": ").append(safeMessage)
}
}
private fun Raise<AddressBookCryptoError>.deriveKey(userWallet: UserWallet): ByteArray {
@ -91,6 +166,7 @@ class AddressBookCipher {
}
private companion object {
const val LOG_TAG = "AddressBook"
const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"
const val AES_ALGORITHM = "AES"
const val NONCE_SIZE_BYTES = 12

View file

@ -5,7 +5,6 @@ import com.tangem.domain.models.network.Network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A single saved address belonging to a [Contact]. */
@Serializable
data class AddressEntry(
@SerialName("id")
@ -15,10 +14,8 @@ data class AddressEntry(
@SerialName("networkId")
@Serializable(with = NetworkRawIdAsStringSerializer::class)
val networkId: Network.RawID,
@SerialName("networkName")
val networkName: String,
@SerialName("memo")
val memo: String?,
val memo: String? = null,
@SerialName("signature")
val signature: String,
)

View file

@ -266,7 +266,6 @@ internal class AddressBookCipherTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
networkName = "Ethereum",
)
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)

View file

@ -70,7 +70,6 @@ class GetVerifiedContactsInteractorTest {
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "AABB",
),

View file

@ -321,7 +321,6 @@ internal class SaveContactInteractorTest {
networkId = networkRawId,
memo = memo,
signature = "sig",
networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {

View file

@ -34,7 +34,6 @@ internal class AddressBookSerializationTest {
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "",
),
@ -71,7 +70,6 @@ internal class AddressBookSerializationTest {
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = "memo",
signature = "sig",
),
@ -102,4 +100,23 @@ internal class AddressBookSerializationTest {
// Assert
assertThat(error).isNotNull()
}
@Test
fun `GIVEN entry with null memo WHEN serialized THEN memo key is omitted`() {
// Arrange — kotlinx omits properties equal to their default, so a null memo must not appear in the JSON,
// matching iOS's encodeIfPresent for String? optionals.
val entry = AddressEntry(
id = AddressEntryId("a1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
)
// Act
val obj = json.parseToJsonElement(json.encodeToString(AddressEntry.serializer(), entry)).jsonObject
// Assert
assertThat(obj.keys).doesNotContain("memo")
}
}

View file

@ -90,7 +90,6 @@ class CheckAddressDuplicateUseCaseTest {
networkId = Network.RawID(networkId),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)

View file

@ -119,7 +119,6 @@ class GetContactsUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)

View file

@ -102,7 +102,6 @@ class ContactNameValidatorTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "AABB",
networkName = "Ethereum",
),
),
)

View file

@ -253,7 +253,6 @@ class ContactSignatureVerifierTest {
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = memo,
signature = signature,
)

View file

@ -1,6 +1,9 @@
package com.tangem.features.addressbook.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.MatchedContact
@ -27,7 +30,7 @@ internal object ContactMatcher {
MatchedContact.ContactAddress(
address = entry.address,
memo = entry.memo,
networkName = entry.networkName,
networkName = entry.displayNetworkName(),
)
}.toImmutableList(),
)
@ -40,4 +43,8 @@ internal object ContactMatcher {
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
?: CryptoPortfolioIcon.Color.Azure,
)
/** Display network name is derived from [AddressEntry.networkId] — it is not stored in the encrypted payload. */
private fun AddressEntry.displayNetworkName(): String =
Blockchain.fromNetworkId(networkId.value)?.fullName ?: networkId.value
}

View file

@ -37,7 +37,6 @@ internal class ContactAddressEntriesConverter {
id = AddressEntryId(UUID.randomUUID().toString()),
address = address,
networkId = Network.RawID(rawId),
networkName = blockchain?.fullName ?: rawId,
memo = memo?.takeIf { hasExtrasSupport },
signature = "",
)

View file

@ -63,7 +63,7 @@ internal class DefaultAddressBookListComponent(
is AddressBookListUM.Content -> AddressBookListScreen(
state = addressBookListUM,
onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
selector.child?.instance?.BottomSheet()

View file

@ -43,7 +43,7 @@ internal fun AddressBookListScreen(
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(modifier = modifier) {
Column(modifier = modifier.fillMaxSize()) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),

View file

@ -90,7 +90,6 @@ internal class ContactMatcherTest {
networkId = Network.RawID(networkId),
memo = memo,
signature = "sig",
networkName = "Ethereum",
)
private companion object {

View file

@ -808,7 +808,6 @@ internal class EditContactModelTest {
id = AddressEntryId("e-1"),
address = "0xAAA",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "sig",
),
@ -816,7 +815,6 @@ internal class EditContactModelTest {
id = AddressEntryId("e-2"),
address = "0xBBB",
networkId = Network.RawID("bsc"),
networkName = "BSC",
memo = null,
signature = "sig",
),
@ -1016,7 +1014,6 @@ internal class EditContactModelTest {
id = AddressEntryId("e-1"),
address = address,
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "sig",
),

View file

@ -186,7 +186,6 @@ internal class AddressBookListModelTest {
id = AddressEntryId("e-$id"),
address = "0xABC",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "sig",
),

View file

@ -172,7 +172,6 @@ internal class UpdateAddressBookListContentTransformerTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
),

View file

@ -589,7 +589,6 @@ internal class SendDestinationModelTest {
id = AddressEntryId("e1"),
address = address,
networkId = Network.RawID(networkRawId),
networkName = "Ethereum",
memo = null,
signature = "",
),