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()
|
?: 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()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
)
|
)
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -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>,
|
||||||
)
|
)
|
||||||
|
|
@ -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)
|
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"),
|
||||||
|
|
|
||||||
|
|
@ -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 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>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue