Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 17:01:57 +01:00
parent e8cba7d699
commit 75cefb2fba
11 changed files with 247 additions and 42 deletions

View file

@ -102,7 +102,7 @@ internal class DefaultAddressBookRepository(
?: return@withLock AddressBookSyncError.Unknown.left() ?: return@withLock AddressBookSyncError.Unknown.left()
val current = currentContacts(contact.walletId, userWallet) val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact 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 AddressBookSyncError.Unknown
} }
.flatMap { blob -> pushBlob(addressBook.walletId, blob) } .flatMap { blob -> pushBlob(userWallet.walletId, blob) }
} }
private suspend fun pushBlob( private suspend fun pushBlob(
@ -210,7 +210,14 @@ internal class DefaultAddressBookRepository(
}, },
onError = { error -> onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $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()
}, },
) )
} }

View file

@ -78,7 +78,7 @@ internal class DefaultAddressBookRepositoryTest {
val contact = createContact(id = "c1", name = "Alice") val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob() val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) 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 // Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first() val result = repository.getContacts(UserWalletId(WALLET_A)).first()
@ -93,7 +93,7 @@ internal class DefaultAddressBookRepositoryTest {
val contact = createContact(id = "c1", name = "Alice") val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob() val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) 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 // Act
repository.getContacts(UserWalletId(WALLET_A)).first() repository.getContacts(UserWalletId(WALLET_A)).first()
@ -112,7 +112,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob() val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) 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 // Act
val result = repository.getAllContacts().first() val result = repository.getAllContacts().first()
@ -128,7 +128,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob() val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) 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 // Act
repository.getAllContacts().first() repository.getAllContacts().first()
@ -174,7 +174,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob() val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(existing)).right() AddressBook(listOf(existing)).right()
val bookSlot = slot<AddressBook>() val bookSlot = slot<AddressBook>()
val newBlob = createBlob() val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() 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()) } 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 @Test
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest { fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
// Arrange // Arrange
@ -265,7 +296,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob() val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(original)).right() AddressBook(listOf(original)).right()
val bookSlot = slot<AddressBook>() val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit coEvery { blobStore.storeBlob(any()) } returns Unit
@ -286,7 +317,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob() val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right() AddressBook(listOf(kept, removed)).right()
val bookSlot = slot<AddressBook>() val bookSlot = slot<AddressBook>()
val newBlob = createBlob() val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
@ -348,7 +379,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob() val blob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
every { cipher.decrypt(blob, userWallet) } returns every { cipher.decrypt(blob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right() AddressBook(listOf(alice, bob)).right()
// Act // Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob") val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")

View file

@ -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 * 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. * 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 * (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 * tampered ciphertext or tag fails the tag check and surfaces as
* [AddressBookCryptoError.DecryptionFailed]. * [AddressBookCryptoError.DecryptionFailed].
@ -41,8 +41,6 @@ class AddressBookCipher {
userWallet: UserWallet, userWallet: UserWallet,
updatedAt: DateTime, updatedAt: DateTime,
): Either<AddressBookCryptoError, AddressBookBlob> = either { ): Either<AddressBookCryptoError, AddressBookBlob> = either {
ensure(addressBook.walletId == userWallet.walletId) { AddressBookCryptoError.WalletMismatch }
val aesKey = deriveKey(userWallet) val aesKey = deriveKey(userWallet)
val plaintext = json.encodeToString(AddressBook.serializer(), addressBook).toByteArray(Charsets.UTF_8) 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) val authTag = cipherWithTag.copyOfRange(fromIndex = tagOffset, toIndex = cipherWithTag.size)
AddressBookBlob( AddressBookBlob(
walletId = addressBook.walletId.stringValue, walletId = userWallet.walletId.stringValue,
updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(), updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(),
nonce = nonce.toHexString().lowercase(), nonce = nonce.toHexString().lowercase(),
ciphertext = ciphertext.toHexString().lowercase(), ciphertext = ciphertext.toHexString().lowercase(),

View file

@ -1,15 +1,10 @@
package com.tangem.domain.addressbook.model package com.tangem.domain.addressbook.model
import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class AddressBook( data class AddressBook(
val walletId: UserWalletId, @SerialName("contacts")
val contacts: List<Contact>, val contacts: List<Contact>,
) )

View file

@ -19,18 +19,24 @@ import kotlinx.serialization.Serializable
* "updatedAt": "2026-05-22T09:00:00.000Z", * "updatedAt": "2026-05-22T09:00:00.000Z",
* "nonce": "", * "nonce": "",
* "ciphertext": "", * "ciphertext": "",
* "auth_tag": "" * "authTag": ""
* } * }
* ``` * ```
*/ */
@Serializable @Serializable
data class AddressBookBlob( data class AddressBookBlob(
@SerialName("version")
val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY] val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY]
@SerialName("walletId")
val walletId: String, val walletId: String,
@SerialName("updatedAt")
val updatedAt: String, val updatedAt: String,
@SerialName("nonce")
val nonce: String, val nonce: String,
@SerialName("ciphertext")
val ciphertext: String, val ciphertext: String,
@SerialName("auth_tag") val authTag: String, @SerialName("authTag")
val authTag: String,
) { ) {
companion object { companion object {

View file

@ -1,15 +1,24 @@
package com.tangem.domain.addressbook.model package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.serialization.NetworkRawIdAsStringSerializer
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** A single saved address belonging to a [Contact]. */ /** A single saved address belonging to a [Contact]. */
@Serializable @Serializable
data class AddressEntry( data class AddressEntry(
@SerialName("id")
val id: AddressEntryId, val id: AddressEntryId,
@SerialName("address")
val address: String, val address: String,
@SerialName("networkId")
@Serializable(with = NetworkRawIdAsStringSerializer::class)
val networkId: Network.RawID, val networkId: Network.RawID,
@SerialName("networkName")
val networkName: String, val networkName: String,
@SerialName("memo")
val memo: String?, val memo: String?,
@SerialName("signature")
val signature: String, val signature: String,
) )

View file

@ -1,6 +1,9 @@
package com.tangem.domain.addressbook.model 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 com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@ -12,12 +15,22 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class Contact( data class Contact(
@SerialName("id")
val id: ContactId, val id: ContactId,
@SerialName("walletId")
@Serializable(with = UserWalletIdAsStringSerializer::class)
val walletId: UserWalletId, val walletId: UserWalletId,
@SerialName("name")
@Serializable(with = ContactNameAsStringSerializer::class)
val name: ContactName, val name: ContactName,
@SerialName("icon")
val icon: String, val icon: String,
@SerialName("iconColor")
val iconColor: String, val iconColor: String,
@SerialName("createdAt")
val createdAt: String, val createdAt: String,
@SerialName("updatedAt")
val updatedAt: String, val updatedAt: String,
@SerialName("addresses")
val addresses: List<AddressEntry>, val addresses: List<AddressEntry>,
) )

View file

@ -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<UserWalletId> {
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<Network.RawID> {
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<ContactName> {
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")
}
}
}

View file

@ -127,18 +127,6 @@ internal class AddressBookCipherTest {
assertThat(cipher.decrypt(second, wallet).rightValue()).isEqualTo(book) 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 @Test
fun `GIVEN blob WHEN decrypt with a wallet of different id THEN WalletMismatch`() { fun `GIVEN blob WHEN decrypt with a wallet of different id THEN WalletMismatch`() {
// Arrange // Arrange
@ -212,7 +200,7 @@ internal class AddressBookCipherTest {
every { card } returns mockk { every { wallets } returns emptyList() } every { card } returns mockk { every { wallets } returns emptyList() }
} }
} }
val book = addressBook(walletId = wallet.walletId) val book = addressBook()
// Act // Act
val result = cipher.encrypt(book, lockedWallet, updatedAt) val result = cipher.encrypt(book, lockedWallet, updatedAt)
@ -259,10 +247,7 @@ internal class AddressBookCipherTest {
// region helpers // region helpers
private fun addressBook(vararg contacts: Contact): AddressBook = private fun addressBook(vararg contacts: Contact): AddressBook =
AddressBook(walletId = wallet.walletId, contacts = contacts.toList()) AddressBook(contacts = contacts.toList())
private fun addressBook(walletId: UserWalletId): AddressBook =
AddressBook(walletId = walletId, contacts = emptyList())
private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact( private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-$name"), id = ContactId("contact-$name"),

View file

@ -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()
}
}

View file

@ -103,7 +103,7 @@ internal class SendDestinationModel @Inject constructor(
private val cryptoCurrency = params.cryptoCurrency private val cryptoCurrency = params.cryptoCurrency
private val userWalletId = params.userWalletId private val userWalletId = params.userWalletId
private val contacts: StateFlow<List<Contact>> = getContactsUseCase(query = "", userWalletId = userWalletId) private val contacts: StateFlow<List<Contact>> = getContactsUseCase(query = "", userWalletId = null)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList()) .stateIn(modelScope, SharingStarted.Eagerly, emptyList())
val addressSelectorNavigation = SlotNavigation<MatchedContact>() val addressSelectorNavigation = SlotNavigation<MatchedContact>()