Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 13:50:13 +01:00
commit 4dec0b44cd
3 changed files with 52 additions and 6 deletions

View file

@ -100,9 +100,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))
}
}
}
@ -181,9 +182,28 @@ internal class DefaultAddressBookRepository(
)
}
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
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<AddressBookSyncError, List<Contact>> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().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
}
}
/**

View file

@ -283,6 +283,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

View file

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