From 3fd67fb474e5b40eb0829d4edac630f07a8a4e30 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 16:51:05 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 34 +- .../interactor/SaveContactInteractor.kt | 104 ++++++ .../usecase/AddressEntrySigningPayload.kt | 2 +- .../usecase/CreateContactUseCase.kt | 57 ---- .../usecase/SignAddressEntriesUseCase.kt | 41 --- .../usecase/UpdateContactUseCase.kt | 43 --- .../interactor/SaveContactInteractorTest.kt | 318 ++++++++++++++++++ .../usecase/CreateContactUseCaseTest.kt | 164 --------- .../usecase/SignAddressEntriesUseCaseTest.kt | 148 -------- .../usecase/UpdateContactUseCaseTest.kt | 145 -------- 10 files changed, 429 insertions(+), 627 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt index 07e8299d97..d16d61ed8a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -2,14 +2,12 @@ package com.tangem.tap.di.domain import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor +import com.tangem.domain.addressbook.interactor.SaveContactInteractor import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.addressbook.usecase.CreateContactUseCase import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase -import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase -import com.tangem.domain.addressbook.usecase.UpdateContactUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -39,12 +37,6 @@ object AddressBookDomainModule { ) } - @Provides - @Singleton - fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { - return SignAddressEntriesUseCase(signUseCase = signUseCase) - } - @Provides @Singleton fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase { @@ -73,30 +65,16 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideCreateContactUseCase( + fun provideSaveContactInteractor( repository: AddressBookRepository, validateContactNameUseCase: ValidateContactNameUseCase, - signAddressEntriesUseCase: SignAddressEntriesUseCase, + signUseCase: SignUseCase, timestampProvider: IsoTimestampProvider, - ): CreateContactUseCase { - return CreateContactUseCase( + ): SaveContactInteractor { + return SaveContactInteractor( repository = repository, validateContactName = validateContactNameUseCase, - signAddressEntries = signAddressEntriesUseCase, - timestampProvider = timestampProvider, - ) - } - - @Provides - @Singleton - fun provideUpdateContactUseCase( - repository: AddressBookRepository, - signAddressEntriesUseCase: SignAddressEntriesUseCase, - timestampProvider: IsoTimestampProvider, - ): UpdateContactUseCase { - return UpdateContactUseCase( - repository = repository, - signAddressEntries = signAddressEntriesUseCase, + signUseCase = signUseCase, timestampProvider = timestampProvider, ) } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt new file mode 100644 index 0000000000..4bcd36a802 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt @@ -0,0 +1,104 @@ +package com.tangem.domain.addressbook.interactor + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SignHashesError +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey +import com.tangem.utils.extensions.toHexString +import java.security.MessageDigest +import java.util.UUID + +class SaveContactInteractor( + private val repository: AddressBookRepository, + private val validateContactName: ValidateContactNameUseCase, + private val signUseCase: SignUseCase, + private val timestampProvider: IsoTimestampProvider, +) { + + suspend fun createContact( + userWallet: UserWallet, + name: String, + iconColor: String, + addressEntries: List, + ): Either = either { + val userWalletId = userWallet.walletId + val validName = validateContactName(userWalletId, name) + .mapLeft(SaveContactError::Name) + .bind() + + val now = timestampProvider.now() + val contact = Contact( + id = ContactId(UUID.randomUUID().toString()), + walletId = userWalletId, + name = validName, + icon = "", + iconColor = iconColor, + createdAt = now, + updatedAt = now, + addressEntries = addressEntries, + ) + val signed = signAddressEntries(userWallet, contact) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed + } + + suspend fun updateContact( + userWallet: UserWallet, + contact: Contact, + name: String, + iconColor: String, + addressEntries: List, + ): Either = either { + val validName = ContactName(name) + .mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) } + .bind() + + val updated = contact.copy( + name = validName, + iconColor = iconColor, + addressEntries = addressEntries, + updatedAt = timestampProvider.now(), + ) + val signed = signAddressEntries(userWallet, updated) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed + } + + private suspend fun signAddressEntries( + userWallet: UserWallet, + contact: Contact, + ): Either = either { + val entries = contact.addressEntries + if (entries.isEmpty()) return@either contact + + val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey) + val hashes = entries.map { entry -> hashEntry(contact, entry) } + val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind() + + val signedEntries = entries.mapIndexed { index, entry -> + entry.copy(signature = signatures[index].toHexString()) + } + contact.copy(addressEntries = signedEntries) + } + + private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray { + val payload = buildAddressEntryPayload(contact, entry) + return MessageDigest.getInstance("SHA-256").digest(payload) + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt index fedf05098a..cd0101bf0e 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt @@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact * Builds the canonical bytes that are signed for a single [AddressEntry]: * `address + networkId + memo + contactId + name`. * - * Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and `GetVerifiedContactsInteractor` + * Shared by `SaveContactInteractor` (which hashes and signs it) and `GetVerifiedContactsInteractor` * (which verifies the signature against it), so the signed and verified payloads can never diverge. */ internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray { diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt deleted file mode 100644 index 3cab6232ed..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import java.util.UUID - -/** - * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique - - * the current time. Every address entry is signed with [userWallet]'s key before the contact is - * persisted, so only signed contacts are ever stored. - */ -class CreateContactUseCase( - private val repository: AddressBookRepository, - private val validateContactName: ValidateContactNameUseCase, - private val signAddressEntries: SignAddressEntriesUseCase, - private val timestampProvider: IsoTimestampProvider, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - name: String, - iconColor: String, - network: Network, - addressEntries: List, - ): Either = either { - val userWalletId = userWallet.walletId - val validName = validateContactName(userWalletId, name) - .mapLeft(SaveContactError::Name) - .bind() - - val now = timestampProvider.now() - val contact = Contact( - id = ContactId(UUID.randomUUID().toString()), - walletId = userWalletId, - name = validName, - icon = "", - iconColor = iconColor, - createdAt = now, - updatedAt = now, - addressEntries = addressEntries, - ) - val signed = signAddressEntries(userWallet, contact) - .mapLeft(SaveContactError::Signing) - .bind() - repository.saveContact(signed) - signed - } -} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt deleted file mode 100644 index 525745b768..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignUseCase -import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey -import com.tangem.utils.extensions.toHexString -import java.security.MessageDigest - -/** - * Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single - * signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + - * contactId + name)` and the produced signature is stored back into [AddressEntry.signature]. - */ -class SignAddressEntriesUseCase( - private val signUseCase: SignUseCase, -) { - - suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either = either { - val entries = contact.addressEntries - if (entries.isEmpty()) return@either contact - - val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey) - val hashes = entries.map { entry -> hashEntry(contact, entry) } - val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind() - - val signedEntries = entries.mapIndexed { index, entry -> - entry.copy(signature = signatures[index].toHexString()) - } - contact.copy(addressEntries = signedEntries) - } - - private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray { - val payload = buildAddressEntryPayload(contact, entry) - return MessageDigest.getInstance("SHA-256").digest(payload) - } -} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt deleted file mode 100644 index 113a870f0f..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactName -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.wallet.UserWallet - -class UpdateContactUseCase( - private val repository: AddressBookRepository, - private val signAddressEntries: SignAddressEntriesUseCase, - private val timestampProvider: IsoTimestampProvider, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - contact: Contact, - name: String, - iconColor: String, - addressEntries: List, - ): Either = either { - val validName = ContactName(name) - .mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) } - .bind() - - val updated = contact.copy( - name = validName, - iconColor = iconColor, - addressEntries = addressEntries, - updatedAt = timestampProvider.now(), - ) - val signed = signAddressEntries(userWallet, updated) - .mapLeft(SaveContactError::Signing) - .bind() - repository.saveContact(signed) - signed - } -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt new file mode 100644 index 0000000000..961e8fb25f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt @@ -0,0 +1,318 @@ +package com.tangem.domain.addressbook.interactor + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.SignHashesError +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SaveContactInteractorTest { + + private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val signUseCase: SignUseCase = mockk() + private val timestampProvider: IsoTimestampProvider = mockk { + every { now() } returns NEW_TIMESTAMP + } + private val interactor = SaveContactInteractor( + repository = repository, + validateContactName = ValidateContactNameUseCase(repository), + signUseCase = signUseCase, + timestampProvider = timestampProvider, + ) + + // MockUserWalletFactory builds each wallet key with publicKey = curve.name bytes → secp256k1 key is "Secp256k1" + private val userWallet: UserWallet = MockUserWalletFactory.create() + private val secp256k1Key = "Secp256k1".toByteArray() + private val networkRawId = Network.RawID("ethereum") + + @BeforeEach + fun resetMocks() { + clearMocks(repository, signUseCase, answers = false) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateContact { + + private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo")) + + @Test + fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest { + // Arrange + stubNoExistingContacts() + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte())) + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns + signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.walletId).isEqualTo(userWallet.walletId) + assertThat(contact.name.value).isEqualTo("Alice") + assertThat(contact.id.value).isNotEmpty() + assertThat(contact.createdAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString()) + } + + @Test + fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() = + runTest { + // Arrange + stubNoExistingContacts() + val twoEntries = listOf( + entry(id = "addr-1", address = "0xabc", memo = "memo"), + entry(id = "addr-2", address = "0xdef", memo = null), + ) + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) + val hashesSlot = slot>() + val publicKeySlot = slot() + coEvery { + signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) + } returns signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries) + + // Assert + assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key) + val persisted = saved.captured + assertThat(hashesSlot.captured.map { it.toHexString() }) + .containsExactly( + expectedHash(persisted, twoEntries[0]).toHexString(), + expectedHash(persisted, twoEntries[1]).toHexString(), + ) + .inOrder() + assertThat(persisted.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString(), signatures[1].toHexString()) + .inOrder() + } + + @Test + fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest { + // Arrange + stubNoExistingContacts() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList()) + + // Assert + assertThat(result.getOrNull()).isEqualTo(saved.captured) + assertThat(saved.captured.addressEntries).isEmpty() + coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } + } + + @Test + fun `GIVEN wallet without a secp256k1 key WHEN createContact THEN Signing NoSigningKey without persisting`() = + runTest { + // Arrange — a locked hot wallet exposes no key; validation must still pass first + val lockedWallet = mockk { + every { walletId } returns userWallet.walletId + every { wallets } returns null + } + stubNoExistingContacts() + + // Act + val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest { + // Arrange + stubNoExistingContacts() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + SignHashesError.SigningFailed(message = "canceled").left() + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled"))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest { + // Arrange + every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice"))) + + // Act + val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest { + // Arrange + stubNoExistingContacts() + + // Act + val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + private fun stubNoExistingContacts() { + every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList()) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class UpdateContact { + + private val updatedEntries = listOf(entry(id = "addr-new", address = "0xnew", memo = "memo")) + + @Test + fun `GIVEN existing contact WHEN updateContact THEN preserves id AND restamps AND persists without uniqueness check`() = + runTest { + // Arrange + val existing = contact(name = "Alice") + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte())) + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns + signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = existing, + name = "Bob", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.id).isEqualTo(existing.id) + assertThat(contact.name.value).isEqualTo("Bob") + assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP) + assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString()) + coVerify(exactly = 0) { repository.getContacts(any()) } + } + + @Test + fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest { + // Arrange + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + SignHashesError.NoSigningKey.left() + + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "Bob", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN blank name WHEN updateContact THEN Name Format without persisting`() = runTest { + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = userWallet.walletId, + name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "TestColor", + createdAt = ORIGINAL_TIMESTAMP, + updatedAt = ORIGINAL_TIMESTAMP, + addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)), + ) + + private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = networkRawId, + memo = memo, + signature = "sig", + networkName = "Ethereum", + ) + + private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { + val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() + + contact.id.value + contact.name.value + return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) + } + + private companion object { + const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z" + const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z" + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt deleted file mode 100644 index cf1d9fba15..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.* -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.* -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CreateContactUseCaseTest { - - private val repository: AddressBookRepository = mockk(relaxUnitFun = true) - private val expectedTimestamp = "2026-06-10T14:30:00.000Z" - private val timestampProvider: IsoTimestampProvider = mockk { - every { now() } returns expectedTimestamp - } - private val signAddressEntries: SignAddressEntriesUseCase = mockk() - private val useCase = CreateContactUseCase( - repository = repository, - validateContactName = ValidateContactNameUseCase(repository), - signAddressEntries = signAddressEntries, - timestampProvider = timestampProvider, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@CreateContactUseCaseTest.walletId } - private val networkRawId = Network.RawID("ethereum") - private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) - private val network: Network = mockk { every { id } returns networkId } - - private val addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-1"), - address = "0xabc", - networkId = networkRawId, - memo = "memo", - signature = "sig", - networkName = "Ethereum", - ), - ) - - private val signedEntries = listOf(addressEntries.first().copy(signature = "signed")) - - @BeforeEach - fun resetMocks() { - clearMocks(repository, signAddressEntries) - // Sign returns the contact with signed entries; the persisted contact must be the signed one. - coEvery { signAddressEntries(eq(userWallet), any()) } answers { - secondArg().copy(addressEntries = signedEntries).right() - } - } - - @Test - fun `create generates ids and persists the signed contact`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit - - val result = useCase( - userWallet = userWallet, - name = "Alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - val contact = result.getOrNull() - assertThat(contact).isEqualTo(saved.captured) - assertThat(contact!!.walletId).isEqualTo(walletId) - assertThat(contact.name.value).isEqualTo("Alice") - assertThat(contact.id.value).isNotEmpty() - assertThat(contact.addressEntries).isEqualTo(signedEntries) - assertThat(contact.createdAt).isEqualTo(expectedTimestamp) - assertThat(contact.updatedAt).isEqualTo(expectedTimestamp) - } - - @Test - fun `signing failure fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() - - val result = useCase( - userWallet = userWallet, - name = "Alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `duplicate name fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf( - listOf( - contact(name = "Alice", iconColor = "TestColor") - ) - ) - - val result = useCase( - userWallet = userWallet, - name = "alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `invalid name fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - - val result = useCase( - userWallet = userWallet, - name = "", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - private fun contact(name: String, iconColor: String): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = iconColor, - createdAt = expectedTimestamp, - updatedAt = expectedTimestamp, - addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-$name"), - address = "0xabc", - networkId = networkRawId, - memo = null, - signature = "sig", - networkName = "Ethereum", - ), - ), - ) -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt deleted file mode 100644 index c4db011355..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.domain.addressbook.model.ContactName -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignUseCase -import com.tangem.utils.extensions.toHexString -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import java.security.MessageDigest - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class SignAddressEntriesUseCaseTest { - - private val signUseCase: SignUseCase = mockk() - private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase) - - // The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1" - private val userWallet: UserWallet = MockUserWalletFactory.create() - private val secp256k1Key = "Secp256k1".toByteArray() - - @BeforeEach - fun resetMocks() { - clearMocks(signUseCase) - } - - @Test - fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest { - // Arrange - val contact = contact( - entry(id = "addr-1", address = "0xabc", memo = "memo"), - entry(id = "addr-2", address = "0xdef", memo = null), - ) - val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) - val hashesSlot = slot>() - val publicKeySlot = slot() - coEvery { - signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) - } returns signatures.right() - - // Act - val result = useCase(userWallet, contact) - - // Assert - // Signatures are applied in entry order, hex-encoded; all other fields are preserved - val expected = contact.copy( - addressEntries = listOf( - contact.addressEntries[0].copy(signature = signatures[0].toHexString()), - contact.addressEntries[1].copy(signature = signatures[1].toHexString()), - ), - ) - assertThat(result.getOrNull()).isEqualTo(expected) - // The wallet's primary secp256k1 key is the one signing - assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key) - // Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order - assertThat(hashesSlot.captured.map { it.toHexString() }) - .containsExactly( - expectedHash(contact, contact.addressEntries[0]).toHexString(), - expectedHash(contact, contact.addressEntries[1]).toHexString(), - ) - .inOrder() - } - - @Test - fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest { - // Arrange - val contact = contact() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.getOrNull()).isEqualTo(contact) - coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } - } - - @Test - fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest { - // Arrange — a locked hot wallet exposes no key - val lockedWallet = mockk { every { wallets } returns null } - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) - - // Act - val result = useCase(lockedWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) - coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } - } - - @Test - fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest { - // Arrange - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) - coEvery { signUseCase(any>(), any(), any()) } returns - SignHashesError.SigningFailed(message = "canceled").left() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled")) - } - - private fun contact(vararg entries: AddressEntry): Contact = Contact( - id = ContactId("contact-1"), - walletId = UserWalletId("011"), - name = requireNotNull(ContactName("Alice").getOrNull()), - icon = "", - iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", - updatedAt = "2026-01-01T00:00:00.000Z", - addressEntries = entries.toList(), - ) - - private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = memo, - signature = "", - networkName = "Ethereum", - ) - - private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { - val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() + - contact.id.value + contact.name.value - return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) - } -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt deleted file mode 100644 index 6fb2cfe6aa..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.domain.addressbook.model.ContactName -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class UpdateContactUseCaseTest { - - private val repository: AddressBookRepository = mockk(relaxUnitFun = true) - private val newTimestamp = "2026-06-10T14:30:00.000Z" - private val originalTimestamp = "2026-01-01T00:00:00.000Z" - private val timestampProvider: IsoTimestampProvider = mockk { - every { now() } returns newTimestamp - } - private val signAddressEntries: SignAddressEntriesUseCase = mockk() - private val useCase = UpdateContactUseCase( - repository = repository, - signAddressEntries = signAddressEntries, - timestampProvider = timestampProvider, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@UpdateContactUseCaseTest.walletId } - private val networkRawId = Network.RawID("ethereum") - - private val updatedEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-new"), - address = "0xnew", - networkId = networkRawId, - memo = "memo", - signature = "sig2", - networkName = "Ethereum", - ), - ) - - private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed")) - - @BeforeEach - fun resetMocks() { - clearMocks(repository, signAddressEntries) - coEvery { signAddressEntries(eq(userWallet), any()) } answers { - secondArg().copy(addressEntries = signedEntries).right() - } - } - - @Test - fun `update preserves id and persists signed changes without checking uniqueness`() = runTest { - val existing = contact(name = "Alice", iconColor = "TestColor") - val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit - - val result = useCase( - userWallet = userWallet, - contact = existing, - name = "Bob", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - val contact = result.getOrNull() - assertThat(contact).isEqualTo(saved.captured) - assertThat(contact!!.id).isEqualTo(existing.id) - assertThat(contact.name.value).isEqualTo("Bob") - assertThat(contact.addressEntries).isEqualTo(signedEntries) - assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved - assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped - coVerify(exactly = 0) { repository.getContacts(any()) } - } - - @Test - fun `signing failure fails without persisting`() = runTest { - coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() - - val result = useCase( - userWallet = userWallet, - contact = contact(name = "Alice", iconColor = "TestColor"), - name = "Bob", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `invalid name fails without persisting`() = runTest { - val result = useCase( - userWallet = userWallet, - contact = contact(name = "Alice", iconColor = "TestColor"), - name = "", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - private fun contact(name: String, iconColor: String): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = iconColor, - createdAt = originalTimestamp, - updatedAt = originalTimestamp, - addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-$name"), - address = "0xabc", - networkId = networkRawId, - memo = null, - signature = "sig", - networkName = "Ethereum", - ), - ), - ) -} \ No newline at end of file