From 75cefb2fba38a6acd41e2695a3df967aae7a9b64 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 17:01:57 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultAddressBookRepository.kt | 13 ++- .../DefaultAddressBookRepositoryTest.kt | 47 ++++++-- .../addressbook/crypto/AddressBookCipher.kt | 6 +- .../domain/addressbook/model/AddressBook.kt | 9 +- .../addressbook/model/AddressBookBlob.kt | 10 +- .../domain/addressbook/model/AddressEntry.kt | 9 ++ .../domain/addressbook/model/Contact.kt | 13 +++ .../serialization/PlainStringSerializers.kt | 56 ++++++++++ .../crypto/AddressBookCipherTest.kt | 19 +--- .../model/AddressBookSerializationTest.kt | 105 ++++++++++++++++++ .../destination/model/SendDestinationModel.kt | 2 +- 11 files changed, 247 insertions(+), 42 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/serialization/PlainStringSerializers.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookSerializationTest.kt diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt index 8fcd5862f8..698016db78 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt @@ -102,7 +102,7 @@ internal class DefaultAddressBookRepository( ?: return@withLock AddressBookSyncError.Unknown.left() val current = currentContacts(contact.walletId, userWallet) val merged = current.filterNot { it.id == contact.id } + contact - persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) + persist(userWallet, AddressBook(contacts = merged)) } } @@ -181,7 +181,7 @@ internal class DefaultAddressBookRepository( ) AddressBookSyncError.Unknown } - .flatMap { blob -> pushBlob(addressBook.walletId, blob) } + .flatMap { blob -> pushBlob(userWallet.walletId, blob) } } private suspend fun pushBlob( @@ -210,7 +210,14 @@ internal class DefaultAddressBookRepository( }, onError = { error -> TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error") - error.toSyncError().left() + 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 + // on purpose — the write stays single-shot; the conflict is still surfaced so the UI can prompt. + if (syncError is AddressBookSyncError.Conflict) { + syncAddressBooks() + } + syncError.left() }, ) } diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt index 307b7cec1c..079d762fe6 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -78,7 +78,7 @@ internal class DefaultAddressBookRepositoryTest { val contact = createContact(id = "c1", name = "Alice") val blob = createBlob() every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) - every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right() // Act val result = repository.getContacts(UserWalletId(WALLET_A)).first() @@ -93,7 +93,7 @@ internal class DefaultAddressBookRepositoryTest { val contact = createContact(id = "c1", name = "Alice") val blob = createBlob() every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) - every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right() // Act repository.getContacts(UserWalletId(WALLET_A)).first() @@ -112,7 +112,7 @@ internal class DefaultAddressBookRepositoryTest { val blob = createBlob() every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) - every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right() // Act val result = repository.getAllContacts().first() @@ -128,7 +128,7 @@ internal class DefaultAddressBookRepositoryTest { val blob = createBlob() every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) - every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right() // Act repository.getAllContacts().first() @@ -174,7 +174,7 @@ internal class DefaultAddressBookRepositoryTest { val storedBlob = createBlob() coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob every { cipher.decrypt(storedBlob, userWallet) } returns - AddressBook(UserWalletId(WALLET_A), listOf(existing)).right() + AddressBook(listOf(existing)).right() val bookSlot = slot() val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() @@ -242,6 +242,37 @@ internal class DefaultAddressBookRepositoryTest { coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) } } + @Test + fun `GIVEN etag conflict WHEN saveContact THEN re-syncs once without retrying the write`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns + errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED) + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left()) + coVerify(exactly = 1) { addressBookApi.syncAddressBooks(any()) } + coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } + } + + @Test + fun `GIVEN network error WHEN saveContact THEN does not re-sync`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse() + + // Act + repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) } + } + @Test fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest { // Arrange @@ -265,7 +296,7 @@ internal class DefaultAddressBookRepositoryTest { val storedBlob = createBlob() coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob every { cipher.decrypt(storedBlob, userWallet) } returns - AddressBook(UserWalletId(WALLET_A), listOf(original)).right() + AddressBook(listOf(original)).right() val bookSlot = slot() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right() coEvery { blobStore.storeBlob(any()) } returns Unit @@ -286,7 +317,7 @@ internal class DefaultAddressBookRepositoryTest { val storedBlob = createBlob() coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob every { cipher.decrypt(storedBlob, userWallet) } returns - AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right() + AddressBook(listOf(kept, removed)).right() val bookSlot = slot() val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() @@ -348,7 +379,7 @@ internal class DefaultAddressBookRepositoryTest { val blob = createBlob() coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob every { cipher.decrypt(blob, userWallet) } returns - AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right() + AddressBook(listOf(alice, bob)).right() // Act val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob") diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt index 832cb02442..4169f94567 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt @@ -26,7 +26,7 @@ import javax.crypto.spec.SecretKeySpec * stored. Each encryption uses a fresh random 12-byte nonce, so encrypting the same book twice * produces different blobs that both decrypt back to the original. * - * The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `auth_tag` field + * The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `authTag` field * (Java appends it to the ciphertext; this class splits it out and re-joins it on decrypt). A * tampered ciphertext or tag fails the tag check and surfaces as * [AddressBookCryptoError.DecryptionFailed]. @@ -41,8 +41,6 @@ class AddressBookCipher { userWallet: UserWallet, updatedAt: DateTime, ): Either = either { - ensure(addressBook.walletId == userWallet.walletId) { AddressBookCryptoError.WalletMismatch } - val aesKey = deriveKey(userWallet) val plaintext = json.encodeToString(AddressBook.serializer(), addressBook).toByteArray(Charsets.UTF_8) @@ -55,7 +53,7 @@ class AddressBookCipher { val authTag = cipherWithTag.copyOfRange(fromIndex = tagOffset, toIndex = cipherWithTag.size) AddressBookBlob( - walletId = addressBook.walletId.stringValue, + walletId = userWallet.walletId.stringValue, updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(), nonce = nonce.toHexString().lowercase(), ciphertext = ciphertext.toHexString().lowercase(), diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt index 0d8aacbe08..9001cde63b 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt @@ -1,15 +1,10 @@ package com.tangem.domain.addressbook.model -import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * All [Contact]s of a single wallet. This is the plaintext payload that - * [com.tangem.domain.addressbook.crypto.AddressBookCipher] encrypts into an - * [AddressBookBlob] and reconstructs on decryption. - */ @Serializable data class AddressBook( - val walletId: UserWalletId, + @SerialName("contacts") val contacts: List, ) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt index 58d75415e5..89cbb8a375 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt @@ -19,18 +19,24 @@ import kotlinx.serialization.Serializable * "updatedAt": "2026-05-22T09:00:00.000Z", * "nonce": "…", * "ciphertext": "…", - * "auth_tag": "…" + * "authTag": "…" * } * ``` */ @Serializable data class AddressBookBlob( + @SerialName("version") val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY] + @SerialName("walletId") val walletId: String, + @SerialName("updatedAt") val updatedAt: String, + @SerialName("nonce") val nonce: String, + @SerialName("ciphertext") val ciphertext: String, - @SerialName("auth_tag") val authTag: String, + @SerialName("authTag") + val authTag: String, ) { companion object { diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt index 616eeebe2a..3678c6f7f1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt @@ -1,15 +1,24 @@ package com.tangem.domain.addressbook.model +import com.tangem.domain.addressbook.model.serialization.NetworkRawIdAsStringSerializer 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") val id: AddressEntryId, + @SerialName("address") val address: String, + @SerialName("networkId") + @Serializable(with = NetworkRawIdAsStringSerializer::class) val networkId: Network.RawID, + @SerialName("networkName") val networkName: String, + @SerialName("memo") val memo: String?, + @SerialName("signature") val signature: String, ) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt index 4ec824d5ef..01af30a032 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt @@ -1,6 +1,9 @@ package com.tangem.domain.addressbook.model +import com.tangem.domain.addressbook.model.serialization.ContactNameAsStringSerializer +import com.tangem.domain.addressbook.model.serialization.UserWalletIdAsStringSerializer import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -12,12 +15,22 @@ import kotlinx.serialization.Serializable */ @Serializable data class Contact( + @SerialName("id") val id: ContactId, + @SerialName("walletId") + @Serializable(with = UserWalletIdAsStringSerializer::class) val walletId: UserWalletId, + @SerialName("name") + @Serializable(with = ContactNameAsStringSerializer::class) val name: ContactName, + @SerialName("icon") val icon: String, + @SerialName("iconColor") val iconColor: String, + @SerialName("createdAt") val createdAt: String, + @SerialName("updatedAt") val updatedAt: String, + @SerialName("addresses") val addresses: List, ) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/serialization/PlainStringSerializers.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/serialization/PlainStringSerializers.kt new file mode 100644 index 0000000000..7ca3db2689 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/serialization/PlainStringSerializers.kt @@ -0,0 +1,56 @@ +package com.tangem.domain.addressbook.model.serialization + +import arrow.core.getOrElse +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/* + * The encrypted address-book payload is a cross-platform (iOS) contract. It carries wallet id, contact + * name and network id as bare strings, so the wrapper domain types must serialize to their underlying + * string rather than the default `{"field": …}` object. These serializers are applied per-property via + * `@Serializable(with = …)`, leaving the global serialization of the shared types untouched. + */ + +/** Serializes [UserWalletId] as its bare [UserWalletId.stringValue]. */ +internal object UserWalletIdAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UserWalletId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UserWalletId) = encoder.encodeString(value.stringValue) + + override fun deserialize(decoder: Decoder): UserWalletId = UserWalletId(decoder.decodeString()) +} + +/** Serializes [Network.RawID] as its bare [Network.RawID.value]. */ +internal object NetworkRawIdAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Network.RawID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Network.RawID) = encoder.encodeString(value.value) + + override fun deserialize(decoder: Decoder): Network.RawID = Network.RawID(decoder.decodeString()) +} + +/** + * Serializes [ContactName] as its bare [ContactName.value]. On read the string goes back through the + * validating [ContactName.invoke] gateway; an invalid name surfaces as a [SerializationException] (the + * cipher maps it to `MalformedBlob`). + */ +internal object ContactNameAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ContactName", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ContactName) = encoder.encodeString(value.value) + + override fun deserialize(decoder: Decoder): ContactName { + val raw = decoder.decodeString() + return ContactName(raw).getOrElse { error -> + throw SerializationException("Invalid contact name in address-book payload: $error") + } + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt index 5264862d12..0da38d0d70 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt @@ -127,18 +127,6 @@ internal class AddressBookCipherTest { assertThat(cipher.decrypt(second, wallet).rightValue()).isEqualTo(book) } - @Test - fun `GIVEN book whose walletId differs from the wallet WHEN encrypt THEN WalletMismatch`() { - // Arrange - val book = addressBook().copy(walletId = UserWalletId("deadbeef")) - - // Act - val result = cipher.encrypt(book, wallet, updatedAt) - - // Assert - assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.WalletMismatch) - } - @Test fun `GIVEN blob WHEN decrypt with a wallet of different id THEN WalletMismatch`() { // Arrange @@ -212,7 +200,7 @@ internal class AddressBookCipherTest { every { card } returns mockk { every { wallets } returns emptyList() } } } - val book = addressBook(walletId = wallet.walletId) + val book = addressBook() // Act val result = cipher.encrypt(book, lockedWallet, updatedAt) @@ -259,10 +247,7 @@ internal class AddressBookCipherTest { // region helpers private fun addressBook(vararg contacts: Contact): AddressBook = - AddressBook(walletId = wallet.walletId, contacts = contacts.toList()) - - private fun addressBook(walletId: UserWalletId): AddressBook = - AddressBook(walletId = walletId, contacts = emptyList()) + AddressBook(contacts = contacts.toList()) private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact( id = ContactId("contact-$name"), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookSerializationTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookSerializationTest.kt new file mode 100644 index 0000000000..fdd1c4bd70 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookSerializationTest.kt @@ -0,0 +1,105 @@ +package com.tangem.domain.addressbook.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Test + +/** + * Locks the JSON shape of the encrypted address-book payload — a cross-platform (iOS) contract. Wallet id, + * contact name and network id must be bare strings, not `{"field": …}` objects, so a Kotlin type change + * can't silently break interop. + */ +internal class AddressBookSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `GIVEN a contact WHEN serialized THEN wrapper types are plain strings`() { + // Arrange + val contact = Contact( + id = ContactId("contact-1"), + walletId = UserWalletId("0a0a0a"), + name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "TestColor", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-05-22T09:00:00.000Z", + addresses = listOf( + AddressEntry( + id = AddressEntryId("addr-1"), + address = "0xabc", + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = null, + signature = "", + ), + ), + ) + + // Act + val obj = json.parseToJsonElement(json.encodeToString(Contact.serializer(), contact)).jsonObject + + // Assert + assertThat(obj["id"]).isEqualTo(JsonPrimitive("contact-1")) + assertThat(obj["walletId"]).isEqualTo(JsonPrimitive("0a0a0a")) + assertThat(obj["name"]).isEqualTo(JsonPrimitive("Alice")) + val entry = obj["addresses"]!!.jsonArray.single().jsonObject + assertThat(entry["id"]).isEqualTo(JsonPrimitive("addr-1")) + assertThat(entry["networkId"]).isEqualTo(JsonPrimitive("ethereum")) + } + + @Test + fun `GIVEN serialized contact WHEN deserialized THEN original is restored`() { + // Arrange + val book = AddressBook( + contacts = listOf( + Contact( + id = ContactId("contact-1"), + walletId = UserWalletId("0a0a0a"), + name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "TestColor", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-05-22T09:00:00.000Z", + addresses = listOf( + AddressEntry( + id = AddressEntryId("addr-1"), + address = "0xabc", + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = "memo", + signature = "sig", + ), + ), + ), + ), + ) + + // Act + val restored = json.decodeFromString( + AddressBook.serializer(), + json.encodeToString(AddressBook.serializer(), book), + ) + + // Assert + assertThat(restored).isEqualTo(book) + } + + @Test + fun `GIVEN payload with an invalid contact name WHEN deserialized THEN fails`() { + // Arrange — empty name violates the ContactName rules + val payload = """{"contacts":[{"id":"c1","walletId":"0a0a0a","name":"",""" + + """"icon":"","iconColor":"c","createdAt":"t","updatedAt":"t","addresses":[]}]}""" + + // Act + val error = runCatching { json.decodeFromString(AddressBook.serializer(), payload) }.exceptionOrNull() + + // Assert + assertThat(error).isNotNull() + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 4189f2876d..d4fc4ca653 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -103,7 +103,7 @@ internal class SendDestinationModel @Inject constructor( private val cryptoCurrency = params.cryptoCurrency private val userWalletId = params.userWalletId - private val contacts: StateFlow> = getContactsUseCase(query = "", userWalletId = userWalletId) + private val contacts: StateFlow> = getContactsUseCase(query = "", userWalletId = null) .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) val addressSelectorNavigation = SlotNavigation()