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 2fbb2ee921..030d7cabdf 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,9 +102,10 @@ internal class DefaultAddressBookRepository( writeMutex.withLock { val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock AddressBookSyncError.Unknown.left() - val current = currentContacts(contact.walletId, userWallet) - val merged = current.filterNot { it.id == contact.id } + contact - persist(userWallet, AddressBook(contacts = merged)) + currentContacts(contact.walletId, userWallet).flatMap { current -> + val merged = current.filterNot { it.id == contact.id } + contact + persist(userWallet, AddressBook(contacts = merged)) + } } } @@ -183,9 +184,28 @@ internal class DefaultAddressBookRepository( ) } - private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List { - val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList() - return decryptContacts(blob, userWallet) + /** + * The contacts currently stored for [userWalletId], as the base a write is merged onto. + * + * A missing blob means no book exists yet → an empty base, so the first contact legitimately creates it. + * But a blob that fails to decrypt must surface as [AddressBookSyncError.DecryptionFailed] rather than an empty + * list: treating a broken book as empty would let a merged write overwrite the (non-empty) backend copy with a + * book built from a single new contact, wiping every existing one. + */ + private suspend fun currentContacts( + userWalletId: UserWalletId, + userWallet: UserWallet, + ): Either> { + val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList().right() + return cipher.decrypt(blob, userWallet) + .map { it.contacts } + .mapLeft { error -> + logger.e( + "Refusing to overwrite address book for wallet $userWalletId: it is stored but decrypt " + + "failed with $error", + ) + AddressBookSyncError.DecryptionFailed + } } /** 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 079d762fe6..1504c70544 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 @@ -288,6 +288,25 @@ internal class DefaultAddressBookRepositoryTest { coVerify(exactly = 0) { blobStore.storeBlob(any()) } } + @Test + fun `GIVEN stored book that fails to decrypt WHEN saveContact THEN returns DecryptionFailed and pushes nothing`() = + runTest { + // Arrange + val storedBlob = createBlob() + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob + every { cipher.decrypt(storedBlob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left() + + // Act + val result = repository.saveContact(createContact(id = "c2", name = "Bob")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.DecryptionFailed.left()) + // The broken book is left untouched: nothing is encrypted, pushed, or stored. + coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) } + coVerify(exactly = 0) { blobStore.storeBlob(any()) } + coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) } + } + @Test fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest { // Arrange diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt index 86b76d1a15..1e01d618cb 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt @@ -22,6 +22,13 @@ sealed interface AddressBookSyncError { /** No network or the request could not be completed. */ data object Network : AddressBookSyncError + /** + * The wallet already has a stored book that could not be decrypted. The write is refused so the broken + * (but non-empty) backend book is not overwritten by a fresh one built on the device — see + * [com.tangem.domain.addressbook.repository.AddressBookRepository.saveContact]. + */ + data object DecryptionFailed : AddressBookSyncError + /** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */ data object Unknown : AddressBookSyncError } \ No newline at end of file