Updated on 2026-08-14
This commit is contained in:
parent
e8cba7d699
commit
75cefb2fba
11 changed files with 247 additions and 42 deletions
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AddressBook>()
|
||||
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<AddressBook>()
|
||||
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<AddressBook>()
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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<AddressBookCryptoError, AddressBookBlob> = 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(),
|
||||
|
|
|
|||
|
|
@ -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<Contact>,
|
||||
)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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<AddressEntry>,
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ internal class SendDestinationModel @Inject constructor(
|
|||
private val cryptoCurrency = params.cryptoCurrency
|
||||
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())
|
||||
|
||||
val addressSelectorNavigation = SlotNavigation<MatchedContact>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue