From b741ed5d02f071259debbf81ec77788b3a98de0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 13:51:02 +0100 Subject: [PATCH 1/2] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 35 ++- core/res/src/main/res/values-ja/strings.xml | 2 + core/res/src/main/res/values/strings.xml | 7 +- .../GetVerifiedContactsInteractor.kt | 53 +--- .../interactor/SaveContactInteractor.kt | 6 +- .../domain/addressbook/model/ContactName.kt | 2 +- .../usecase/ValidateContactNameUseCase.kt | 36 --- .../validation/ContactNameValidator.kt | 39 +++ .../verification/ContactSignatureVerifier.kt | 64 +++++ .../GetVerifiedContactsInteractorTest.kt | 176 ++---------- .../interactor/SaveContactInteractorTest.kt | 34 +-- .../usecase/ValidateContactNameUseCaseTest.kt | 82 ------ .../validation/ContactNameValidatorTest.kt | 109 ++++++++ .../ContactSignatureVerifierTest.kt | 263 ++++++++++++++++++ .../editcontact/model/EditContactModel.kt | 6 +- .../list/model/AddressBookListModel.kt | 8 +- .../editcontact/model/EditContactModelTest.kt | 14 +- .../list/model/AddressBookListModelTest.kt | 8 +- 18 files changed, 578 insertions(+), 366 deletions(-) delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidator.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifier.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidatorTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifierTest.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 c7c52c4c44..1f7b799ea2 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 @@ -12,7 +12,8 @@ import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase -import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.addressbook.validation.ContactNameValidator +import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.transaction.usecase.SignUseCase @@ -42,8 +43,26 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase { - return ValidateContactNameUseCase(repository = repository) + fun provideContactSignatureVerifier( + verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, + userWalletsListRepository: UserWalletsListRepository, + ): ContactSignatureVerifier { + return ContactSignatureVerifier( + verifyMessages = verifyMessagesUseCase, + userWalletsListRepository = userWalletsListRepository, + ) + } + + @Provides + @Singleton + fun provideContactNameValidator( + repository: AddressBookRepository, + contactSignatureVerifier: ContactSignatureVerifier, + ): ContactNameValidator { + return ContactNameValidator( + repository = repository, + contactSignatureVerifier = contactSignatureVerifier, + ) } @Provides @@ -56,13 +75,11 @@ object AddressBookDomainModule { @Singleton fun provideGetVerifiedContactsInteractor( getContactsUseCase: GetContactsUseCase, - verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, - userWalletsListRepository: UserWalletsListRepository, + contactSignatureVerifier: ContactSignatureVerifier, ): GetVerifiedContactsInteractor { return GetVerifiedContactsInteractor( getContacts = getContactsUseCase, - verifyMessages = verifyMessagesUseCase, - userWalletsListRepository = userWalletsListRepository, + contactSignatureVerifier = contactSignatureVerifier, ) } @@ -70,13 +87,13 @@ object AddressBookDomainModule { @Singleton fun provideSaveContactInteractor( repository: AddressBookRepository, - validateContactNameUseCase: ValidateContactNameUseCase, + contactNameValidator: ContactNameValidator, signUseCase: SignUseCase, timestampProvider: IsoTimestampProvider, ): SaveContactInteractor { return SaveContactInteractor( repository = repository, - validateContactName = validateContactNameUseCase, + validateContactName = contactNameValidator, signUseCase = signUseCase, timestampProvider = timestampProvider, ) diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index f6d2f9c189..0cb5e2e92d 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -2275,6 +2275,8 @@ このトークンを受け取るには、Hederaアカウントに関連付ける必要があります。 トークンを関連付ける %sが不足しています。このトークンを関連付けるには、Hederaアカウントに資金を追加してください。 + カードの有効化が正常に完了していないことが判明しました。原因として、お使いの端末のNFC機能の不具合、またはカードをスマートフォンに正しくかざせていない可能性があります。詳しくはサポートまでお問い合わせください。 + 対応が必要です。ウォレットは使用しないでください。 取引をキャンセルしてもよろしいですか? 取引を再試行することはできません %1$s %2$sの取引が完了しませんでした。もう一度試して完了してください。 未完了の取引があります diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 9855758ebb..5f233f9e05 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -108,7 +108,7 @@ Contact Contact name Copy address - Contact added + Contact saved Couldn\'t create contact. Please try again later. Delete contact This contact will be deleted from all your address books @@ -1285,12 +1285,14 @@ Available from Available up to You get + This token is not supported. Please choose a different token to buy. Service is provided by an external provider. \nTangem is not responsible. You can close this screen and check the transaction status on the token details screen. Up to Via via %s You will pay + %s is not supported Group Group by networks Sort by balance @@ -2077,6 +2079,9 @@ Unable to hide %s N/A Show QR code + Can’t load data of the token + Go to swap + Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees Swap now diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt index 0c298b3b38..189080f135 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt @@ -1,65 +1,20 @@ package com.tangem.domain.addressbook.interactor -import arrow.core.Either -import arrow.core.right -import com.tangem.domain.addressbook.model.AddressEntriesVerification -import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.usecase.GetContactsUseCase -import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase -import com.tangem.utils.extensions.hexToBytesOrNull import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class GetVerifiedContactsInteractor( private val getContacts: GetContactsUseCase, - private val verifyMessages: VerifySecp256k1MessagesUseCase, - private val userWalletsListRepository: UserWalletsListRepository, + private val contactSignatureVerifier: ContactSignatureVerifier, ) { - operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + fun getVerifiedContacts(query: String, userWalletId: UserWalletId? = null): Flow> { return getContacts(query, userWalletId).map { contacts -> - val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } - contacts.mapNotNull { contact -> - val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null - val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null - VerifiedContact( - contact = contact.copy(addresses = verification.valid), - invalidEntries = verification.invalid, - ) - } + contactSignatureVerifier.verifyContacts(contacts) } } - - private fun verify( - userWallet: UserWallet, - contact: Contact, - ): Either { - val entries = contact.addresses - if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right() - - // Entries with a malformed (non-hex) signature can't be verified — they are invalid by format. - val wellFormed = entries.mapNotNull { entry -> - entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature } - } - val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) } - val signatures = wellFormed.map { (_, signature) -> signature } - - return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures) - .map { flags -> - val validIds = wellFormed - .filterIndexed { index, _ -> flags[index] } - .mapTo(HashSet()) { (entry, _) -> entry.id } - - AddressEntriesVerification( - valid = entries.filter { it.id in validIds }, - invalid = entries.filterNot { it.id in validIds }, - ) - } - } } \ No newline at end of file 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 index fae60dbb42..7aa3f4d5ed 100644 --- 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 @@ -10,8 +10,8 @@ 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.addressbook.validation.ContactNameValidator import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.SignHashesError import com.tangem.domain.transaction.usecase.SignUseCase @@ -22,7 +22,7 @@ import java.util.UUID class SaveContactInteractor( private val repository: AddressBookRepository, - private val validateContactName: ValidateContactNameUseCase, + private val validateContactName: ContactNameValidator, private val signUseCase: SignUseCase, private val timestampProvider: IsoTimestampProvider, ) { @@ -34,7 +34,7 @@ class SaveContactInteractor( addresses: List, ): Either = either { val userWalletId = userWallet.walletId - val validName = validateContactName(userWalletId, name) + val validName = validateContactName.validate(userWalletId, name) .mapLeft(SaveContactError::Name) .bind() diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt index 37b7e98f0e..5ce109842b 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable * * The only way to obtain an instance is the validating [invoke] factory, which enforces the * address-book naming rules. Uniqueness within a wallet is **not** enforced here — it requires - * access to the repository and lives in `ValidateContactNameUseCase`. + * access to the repository and lives in `ContactNameValidator`. */ @Serializable @ConsistentCopyVisibility diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt deleted file mode 100644 index 891cad072e..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import arrow.core.raise.ensure -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.model.ContactName -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.first - -/** - * Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within - * the wallet. - */ -class ValidateContactNameUseCase( - private val repository: AddressBookRepository, -) { - - suspend operator fun invoke( - walletId: UserWalletId, - name: String, - ): Either = either { - val validName = ContactName(name) - .mapLeft(ContactNameValidationError::Format) - .bind() - - val contacts = repository.getContacts(walletId).first() - val isDuplicate = contacts.any { contact -> - contact.name.value.equals(validName.value, ignoreCase = true) - } - ensure(!isDuplicate) { ContactNameValidationError.Duplicate } - - validName - } -} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidator.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidator.kt new file mode 100644 index 0000000000..0e19afa84a --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidator.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.addressbook.validation + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.verification.ContactSignatureVerifier +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within the + * wallet. + * + * Uniqueness is enforced only against **verified** contacts (see [ContactSignatureVerifier.isNameVerified]): + * a spoofed or tampered contact synced from another device must not be able to reserve a name. Reads the + * local snapshot via [AddressBookRepository.getContactsSync] (validation runs on live keystrokes) and + * filters to same-name contacts before verifying, so signature checks fire only on an actual collision. + */ +class ContactNameValidator( + private val repository: AddressBookRepository, + private val contactSignatureVerifier: ContactSignatureVerifier, +) { + + suspend fun validate(walletId: UserWalletId, name: String): Either = + either { + val validName = ContactName(name) + .mapLeft(ContactNameValidationError::Format) + .bind() + + val sameName = repository.getContactsSync(walletId) + .filter { it.name.value.equals(validName.value, ignoreCase = true) } + val isDuplicate = sameName.any { contactSignatureVerifier.isNameVerified(it) } + ensure(!isDuplicate) { ContactNameValidationError.Duplicate } + + validName + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifier.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifier.kt new file mode 100644 index 0000000000..18a09bab59 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifier.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.addressbook.verification + +import arrow.core.Either +import arrow.core.right +import com.tangem.domain.addressbook.model.AddressEntriesVerification +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.VerifyMessagesError +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase +import com.tangem.utils.extensions.hexToBytesOrNull + +class ContactSignatureVerifier( + private val verifyMessages: VerifySecp256k1MessagesUseCase, + private val userWalletsListRepository: UserWalletsListRepository, +) { + + suspend fun verifyContacts(contacts: List): List { + val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } + return contacts.mapNotNull { contact -> + val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null + val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null + VerifiedContact( + contact = contact.copy(addresses = verification.valid), + invalidEntries = verification.invalid, + ) + } + } + + suspend fun isNameVerified(contact: Contact): Boolean { + val userWallet = userWalletsListRepository.userWalletsSync() + .firstOrNull { it.walletId == contact.walletId } ?: return false + return verify(userWallet, contact).getOrNull()?.valid?.isNotEmpty() == true + } + + private fun verify( + userWallet: UserWallet, + contact: Contact, + ): Either { + val entries = contact.addresses + if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right() + + // Entries with a malformed (non-hex) signature can't be verified — they are invalid by format. + val wellFormed = entries.mapNotNull { entry -> + entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature } + } + val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) } + val signatures = wellFormed.map { (_, signature) -> signature } + + return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures) + .map { flags -> + val validIds = wellFormed + .filterIndexed { index, _ -> flags[index] } + .mapTo(HashSet()) { (entry, _) -> entry.id } + + AddressEntriesVerification( + valid = entries.filter { it.id in validIds }, + invalid = entries.filterNot { it.id in validIds }, + ) + } + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt index 2b8c783929..9f2c7c87d1 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt @@ -1,7 +1,5 @@ package com.tangem.domain.addressbook.interactor -import arrow.core.left -import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.AddressEntryId @@ -10,19 +8,14 @@ import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.usecase.GetContactsUseCase -import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.addressbook.verification.ContactSignatureVerifier 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.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase -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 io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -34,157 +27,37 @@ import org.junit.jupiter.api.TestInstance class GetVerifiedContactsInteractorTest { private val getContacts: GetContactsUseCase = mockk() - private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk() - private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val contactSignatureVerifier: ContactSignatureVerifier = mockk() private val interactor = GetVerifiedContactsInteractor( getContacts = getContacts, - verifyMessages = verifyMessages, - userWalletsListRepository = userWalletsListRepository, + contactSignatureVerifier = contactSignatureVerifier, ) private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsInteractorTest.walletId } @BeforeEach fun resetMocks() { - clearMocks(getContacts, verifyMessages, userWalletsListRepository) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + clearMocks(getContacts, contactSignatureVerifier) } @Test - fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { - // Arrange - val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB") - val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD") - val contact = contact(valid, invalid) - stubContacts(contact) - every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right() - - // Act - val result = interactor(query = "").first() - - // Assert - assertThat(result).containsExactly( - VerifiedContact( - contact = contact.copy(addresses = listOf(valid)), - invalidEntries = listOf(invalid), - ), - ) - } - - @Test - fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest { - // Arrange - val contact = contact( - entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"), - entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), - ) - stubContacts(contact) - val messagesSlot = slot>() - val signaturesSlot = slot>() - every { - verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) - } returns listOf(true, true).right() - - // Act - interactor(query = "").first() - - // Assert - assertThat(messagesSlot.captured.map { String(it) }) - .containsExactly( - expectedPayload(contact, contact.addresses[0]), - expectedPayload(contact, contact.addresses[1]), - ) - .inOrder() - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() - } - - @Test - fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest { - // Arrange - val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") - val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") - val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF") - val contact = contact(valid1, invalid, valid2) - stubContacts(contact) - every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right() - - // Act - val result = interactor(query = "").first().single() - - // Assert - assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder() - assertThat(result.invalidEntries).containsExactly(invalid) - } - - @Test - fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest { - // Arrange - val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") - val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB") - val contact = contact(malformed, signed) - stubContacts(contact) - val signaturesSlot = slot>() - every { - verifyMessages(eq(userWallet), any(), capture(signaturesSlot)) - } returns listOf(true).right() - - // Act - val result = interactor(query = "").first().single() - - // Assert - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") - assertThat(result.contact.addresses).containsExactly(signed) - assertThat(result.invalidEntries).containsExactly(malformed) - } - - @Test - fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest { + fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest { // Arrange val contact = contact() - stubContacts(contact) + val verified = VerifiedContact(contact = contact, invalidEntries = emptyList()) + every { getContacts(query = "query", userWalletId = walletId) } returns flowOf(listOf(contact)) + coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns listOf(verified) // Act - val result = interactor(query = "").first().single() + val result = interactor.getVerifiedContacts(query = "query", userWalletId = walletId).first() // Assert - assertThat(result.contact.addresses).isEmpty() - assertThat(result.invalidEntries).isEmpty() - verify(exactly = 0) { verifyMessages(any(), any(), any()) } + assertThat(result).containsExactly(verified) + coVerify(exactly = 1) { contactSignatureVerifier.verifyContacts(listOf(contact)) } } - @Test - fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() - stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))) - - // Act - val result = interactor(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))) - every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() - - // Act - val result = interactor(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - private fun stubContacts(vararg contacts: Contact) { - every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList()) - } - - private fun contact(vararg entries: AddressEntry): Contact = Contact( + private fun contact(): Contact = Contact( id = ContactId("contact-1"), walletId = walletId, name = requireNotNull(ContactName("Alice").getOrNull()), @@ -192,18 +65,15 @@ class GetVerifiedContactsInteractorTest { iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", - addresses = entries.toList(), + addresses = listOf( + AddressEntry( + id = AddressEntryId("addr-1"), + address = "0xabc", + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = null, + signature = "AABB", + ), + ), ) - - private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - networkName = "Ethereum", - memo = memo, - signature = signature, - ) - - private fun expectedPayload(contact: Contact, entry: AddressEntry): String = - entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value } \ 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 index 15d13523f4..9af600b7b8 100644 --- 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 @@ -14,10 +14,9 @@ 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.validation.ContactNameValidator 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 @@ -27,7 +26,6 @@ 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 @@ -39,13 +37,14 @@ import java.security.MessageDigest internal class SaveContactInteractorTest { private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val contactNameValidator: ContactNameValidator = mockk() private val signUseCase: SignUseCase = mockk() private val timestampProvider: IsoTimestampProvider = mockk { every { now() } returns NEW_TIMESTAMP } private val interactor = SaveContactInteractor( repository = repository, - validateContactName = ValidateContactNameUseCase(repository), + validateContactName = contactNameValidator, signUseCase = signUseCase, timestampProvider = timestampProvider, ) @@ -57,7 +56,7 @@ internal class SaveContactInteractorTest { @BeforeEach fun resetMocks() { - clearMocks(repository, signUseCase, answers = false) + clearMocks(repository, contactNameValidator, signUseCase, answers = false) } @Nested @@ -69,7 +68,7 @@ internal class SaveContactInteractorTest { @Test fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest { // Arrange - stubNoExistingContacts() + stubValidName(name = "Alice") val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte())) coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns signatures.right() @@ -95,7 +94,7 @@ internal class SaveContactInteractorTest { fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() = runTest { // Arrange - stubNoExistingContacts() + stubValidName(name = "Alice") val twoEntries = listOf( entry(id = "addr-1", address = "0xabc", memo = "memo"), entry(id = "addr-2", address = "0xdef", memo = null), @@ -129,7 +128,7 @@ internal class SaveContactInteractorTest { @Test fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest { // Arrange - stubNoExistingContacts() + stubValidName(name = "Alice") val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit.right() @@ -150,7 +149,7 @@ internal class SaveContactInteractorTest { every { walletId } returns userWallet.walletId every { wallets } returns null } - stubNoExistingContacts() + stubValidName(name = "Alice") // Act val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries) @@ -164,7 +163,7 @@ internal class SaveContactInteractorTest { @Test fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest { // Arrange - stubNoExistingContacts() + stubValidName(name = "Alice") coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns SignHashesError.SigningFailed(message = "canceled").left() @@ -180,7 +179,8 @@ internal class SaveContactInteractorTest { @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"))) + coEvery { contactNameValidator.validate(userWallet.walletId, "alice") } returns + ContactNameValidationError.Duplicate.left() // Act val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries) @@ -194,7 +194,8 @@ internal class SaveContactInteractorTest { @Test fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest { // Arrange - stubNoExistingContacts() + coEvery { contactNameValidator.validate(userWallet.walletId, "") } returns + ContactNameValidationError.Format(ContactName.Error.Empty).left() // Act val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries) @@ -208,7 +209,7 @@ internal class SaveContactInteractorTest { @Test fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest { // Arrange - stubNoExistingContacts() + stubValidName(name = "Alice") coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns listOf(byteArrayOf(0x01)).right() coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left() @@ -221,8 +222,9 @@ internal class SaveContactInteractorTest { .isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict)) } - private fun stubNoExistingContacts() { - every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList()) + private fun stubValidName(name: String) { + coEvery { contactNameValidator.validate(userWallet.walletId, name) } returns + requireNotNull(ContactName(name).getOrNull()).right() } } @@ -261,7 +263,7 @@ internal class SaveContactInteractorTest { assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP) assertThat(contact.addresses.map { it.signature }) .containsExactly(signatures[0].toHexString()) - coVerify(exactly = 0) { repository.getContacts(any()) } + coVerify(exactly = 0) { contactNameValidator.validate(any(), any()) } } @Test diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt deleted file mode 100644 index 3de511c5ad..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.error.ContactNameValidationError -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.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.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 ValidateContactNameUseCaseTest { - - private val repository: AddressBookRepository = mockk(relaxUnitFun = true) - private val useCase = ValidateContactNameUseCase(repository) - - private val walletId = UserWalletId("011") - - @BeforeEach - fun resetMocks() { - clearMocks(repository) - } - - @Test - fun `format error is propagated`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - - val result = useCase(walletId, name = "") - - assertThat(result.leftOrNull()) - .isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty)) - } - - @Test - fun `duplicate name in same wallet is rejected case-insensitively`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) - - val result = useCase(walletId, name = "alice") - - assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate) - } - - @Test - fun `unique name is accepted`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) - - val result = useCase(walletId, name = "Bob") - - assertThat(result.getOrNull()?.value).isEqualTo("Bob") - } - - private fun contact(name: String): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", - updatedAt = "2026-01-01T00:00:00.000Z", - addresses = listOf( - AddressEntry( - id = AddressEntryId("addr-$name"), - address = "0xabc", - networkId = Network.RawID("ethereum"), - 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/validation/ContactNameValidatorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidatorTest.kt new file mode 100644 index 0000000000..cfe8b37d2b --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/validation/ContactNameValidatorTest.kt @@ -0,0 +1,109 @@ +package com.tangem.domain.addressbook.validation + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.error.ContactNameValidationError +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.verification.ContactSignatureVerifier +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +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 ContactNameValidatorTest { + + private val repository: AddressBookRepository = mockk() + private val contactSignatureVerifier: ContactSignatureVerifier = mockk() + + private val validator = ContactNameValidator( + repository = repository, + contactSignatureVerifier = contactSignatureVerifier, + ) + + private val walletId = UserWalletId("011") + + @BeforeEach + fun resetMocks() { + clearMocks(repository, contactSignatureVerifier) + } + + @Test + fun `GIVEN blank name WHEN validate THEN format error is propagated`() = runTest { + // Act + val result = validator.validate(walletId, name = "") + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty)) + } + + @Test + fun `GIVEN same-name verified contact WHEN validate THEN Duplicate rejected case-insensitively`() = runTest { + // Arrange + coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice")) + coEvery { contactSignatureVerifier.isNameVerified(any()) } returns true + + // Act + val result = validator.validate(walletId, name = "alice") + + // Assert + assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate) + } + + @Test + fun `GIVEN same-name but unverified spoofed contact WHEN validate THEN name is accepted`() = runTest { + // Arrange — a contact synced from another device whose signature does not verify must not reserve a name + coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice")) + coEvery { contactSignatureVerifier.isNameVerified(any()) } returns false + + // Act + val result = validator.validate(walletId, name = "alice") + + // Assert + assertThat(result.getOrNull()?.value).isEqualTo("alice") + } + + @Test + fun `GIVEN no same-name contacts WHEN validate THEN accepted without verifying`() = runTest { + // Arrange + coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice")) + + // Act + val result = validator.validate(walletId, name = "Bob") + + // Assert + assertThat(result.getOrNull()?.value).isEqualTo("Bob") + coVerify(exactly = 0) { contactSignatureVerifier.isNameVerified(any()) } + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "KekColor", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addresses = listOf( + AddressEntry( + id = AddressEntryId("addr-$name"), + address = "0xabc", + networkId = Network.RawID("ethereum"), + memo = null, + signature = "AABB", + networkName = "Ethereum", + ), + ), + ) +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifierTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifierTest.kt new file mode 100644 index 0000000000..fd4a322926 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/verification/ContactSignatureVerifierTest.kt @@ -0,0 +1,263 @@ +package com.tangem.domain.addressbook.verification + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +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.model.VerifiedContact +import com.tangem.domain.common.wallets.UserWalletsListRepository +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.VerifyMessagesError +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase +import com.tangem.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ContactSignatureVerifierTest { + + private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val verifier = ContactSignatureVerifier( + verifyMessages = verifyMessages, + userWalletsListRepository = userWalletsListRepository, + ) + + private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@ContactSignatureVerifierTest.walletId } + + @BeforeEach + fun resetMocks() { + clearMocks(verifyMessages, userWalletsListRepository) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class VerifyContacts { + + @Test + fun `GIVEN mixed entries WHEN verifyContacts THEN displays only valid AND keeps invalid for analytics`() = + runTest { + // Arrange + val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB") + val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD") + val contact = contact(valid, invalid) + every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right() + + // Act + val result = verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(result).containsExactly( + VerifiedContact( + contact = contact.copy(addresses = listOf(valid)), + invalidEntries = listOf(invalid), + ), + ) + } + + @Test + fun `GIVEN contact with entries WHEN verifyContacts THEN verifies each entry payload and its signature`() = + runTest { + // Arrange + val contact = contact( + entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"), + entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), + ) + val messagesSlot = slot>() + val signaturesSlot = slot>() + every { + verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) + } returns listOf(true, true).right() + + // Act + verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(messagesSlot.captured.map { String(it) }) + .containsExactly( + expectedPayload(contact, contact.addresses[0]), + expectedPayload(contact, contact.addresses[1]), + ) + .inOrder() + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() + } + + @Test + fun `GIVEN some entries fail verification WHEN verifyContacts THEN partitions them preserving order`() = + runTest { + // Arrange + val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") + val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") + val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF") + val contact = contact(valid1, invalid, valid2) + every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right() + + // Act + val result = verifier.verifyContacts(listOf(contact)).single() + + // Assert + assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder() + assertThat(result.invalidEntries).containsExactly(invalid) + } + + @Test + fun `GIVEN malformed signature WHEN verifyContacts THEN that entry is invalid and excluded from verification`() = + runTest { + // Arrange + val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") + val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB") + val contact = contact(malformed, signed) + val signaturesSlot = slot>() + every { + verifyMessages(eq(userWallet), any(), capture(signaturesSlot)) + } returns listOf(true).right() + + // Act + val result = verifier.verifyContacts(listOf(contact)).single() + + // Assert + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") + assertThat(result.contact.addresses).containsExactly(signed) + assertThat(result.invalidEntries).containsExactly(malformed) + } + + @Test + fun `GIVEN contact with no entries WHEN verifyContacts THEN keeps contact without verifying`() = runTest { + // Arrange + val contact = contact() + + // Act + val result = verifier.verifyContacts(listOf(contact)).single() + + // Assert + assertThat(result.contact.addresses).isEmpty() + assertThat(result.invalidEntries).isEmpty() + verify(exactly = 0) { verifyMessages(any(), any(), any()) } + } + + @Test + fun `GIVEN wallet cannot be resolved WHEN verifyContacts THEN contact is dropped`() = runTest { + // Arrange + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + + // Act + val result = verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN verification fails WHEN verifyContacts THEN contact is dropped`() = runTest { + // Arrange + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() + + // Act + val result = verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(result).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsNameVerified { + + @Test + fun `GIVEN at least one valid entry WHEN isNameVerified THEN true`() = runTest { + // Arrange + val contact = contact( + entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"), + entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), + ) + every { verifyMessages(any(), any(), any()) } returns listOf(false, true).right() + + // Act & Assert + assertThat(verifier.isNameVerified(contact)).isTrue() + } + + @Test + fun `GIVEN all entries invalid WHEN isNameVerified THEN false`() = runTest { + // Arrange + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + every { verifyMessages(any(), any(), any()) } returns listOf(false).right() + + // Act & Assert + assertThat(verifier.isNameVerified(contact)).isFalse() + } + + @Test + fun `GIVEN contact with no entries WHEN isNameVerified THEN false`() = runTest { + // Arrange + val contact = contact() + + // Act & Assert + assertThat(verifier.isNameVerified(contact)).isFalse() + verify(exactly = 0) { verifyMessages(any(), any(), any()) } + } + + @Test + fun `GIVEN wallet cannot be resolved WHEN isNameVerified THEN false`() = runTest { + // Arrange + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + + // Act & Assert + assertThat(verifier.isNameVerified(contact)).isFalse() + } + + @Test + fun `GIVEN verification fails WHEN isNameVerified THEN false`() = runTest { + // Arrange + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() + + // Act & Assert + assertThat(verifier.isNameVerified(contact)).isFalse() + } + } + + private fun contact(vararg entries: AddressEntry): Contact = Contact( + id = ContactId("contact-1"), + walletId = walletId, + name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "KekColor", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addresses = entries.toList(), + ) + + private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = memo, + signature = signature, + ) + + private fun expectedPayload(contact: Contact, entry: AddressEntry): String = + entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index 18b75b8a77..bbc1caed26 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -20,7 +20,7 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase -import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.addressbook.validation.ContactNameValidator import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.wallet.UserWallet @@ -55,7 +55,7 @@ internal class EditContactModel @Inject constructor( private val resultHolder: AddressBookResultHolder, private val messageSender: UiMessageSender, private val userWalletsListRepository: UserWalletsListRepository, - private val validateContactNameUseCase: ValidateContactNameUseCase, + private val contactNameValidator: ContactNameValidator, private val saveContactInteractor: SaveContactInteractor, private val getContactByIdUseCase: GetContactByIdUseCase, private val deleteContactUseCase: DeleteContactUseCase, @@ -417,7 +417,7 @@ internal class EditContactModel @Inject constructor( private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? { if (name.isBlank()) return null if (name == loadedContact.value?.name?.value) return null - val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null + val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null return ContactNameErrorConverter().convert(error) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index c000ba3ea3..5271275291 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -58,12 +58,16 @@ internal class AddressBookListModel @Inject constructor( private val selectedWalletId = MutableStateFlow(value = null) private val allContacts: SharedFlow> = - getVerifiedContactsInteractor(query = "", userWalletId = null) + getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) .shareIn(modelScope, SharingStarted.Lazily, replay = 1) init { val matchedContacts = searchQuery.flatMapLatest { query -> - if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null) + if (query.isBlank()) { + allContacts + } else { + getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null) + } } combine( allContacts, diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index 7374878263..0a5558d152 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -19,7 +19,7 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor import com.tangem.domain.addressbook.model.* import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase -import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.addressbook.validation.ContactNameValidator import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -56,7 +56,7 @@ internal class EditContactModelTest { private val resultHolder = AddressBookResultHolder() private val messageSender: UiMessageSender = mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) - private val validateContactNameUseCase: ValidateContactNameUseCase = mockk() + private val contactNameValidator: ContactNameValidator = mockk() private val saveContactInteractor: SaveContactInteractor = mockk() private val getContactByIdUseCase: GetContactByIdUseCase = mockk() private val deleteContactUseCase: DeleteContactUseCase = mockk() @@ -75,7 +75,7 @@ internal class EditContactModelTest { fun setUp() { // Default: no wallets loaded, name always valid. Individual tests override as needed. setupWallets(wallets = emptyList(), selected = null) - coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right() + coEvery { contactNameValidator.validate(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right() every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData // No existing contact by default; a StateFlow never surfaces a contact and never completes. @@ -396,7 +396,7 @@ internal class EditContactModelTest { val walletA = createWallet(id = "aa", name = "Wallet A") setupWallets(wallets = listOf(walletA), selected = walletA) coEvery { - validateContactNameUseCase(any(), any()) + contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left() val model = createModel(testScope = this) advanceUntilIdle() @@ -434,7 +434,7 @@ internal class EditContactModelTest { val walletB = createWallet(id = "bb", name = "Wallet B") setupWallets(wallets = listOf(walletA, walletB), selected = walletA) coEvery { - validateContactNameUseCase(walletB.walletId, "Satoshi") + contactNameValidator.validate(walletB.walletId, "Satoshi") } returns ContactNameValidationError.Duplicate.left() val model = createModel(testScope = this) advanceUntilIdle() @@ -489,7 +489,7 @@ internal class EditContactModelTest { // Arrange val walletA = createWallet(id = "aa", name = "Wallet A") setupWallets(wallets = listOf(walletA), selected = walletA) - coEvery { validateContactNameUseCase(any(), any()) } returns ContactNameValidationError.Duplicate.left() + coEvery { contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left() val model = createModel(testScope = this) advanceUntilIdle() @@ -927,7 +927,7 @@ internal class EditContactModelTest { resultHolder = resultHolder, messageSender = messageSender, userWalletsListRepository = userWalletsListRepository, - validateContactNameUseCase = validateContactNameUseCase, + contactNameValidator = contactNameValidator, saveContactInteractor = saveContactInteractor, getContactByIdUseCase = getContactByIdUseCase, deleteContactUseCase = deleteContactUseCase, diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt index d76a4df0b9..198584da64 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -61,7 +61,7 @@ internal class AddressBookListModelTest { @Test fun `GIVEN feature just opened WHEN contacts not yet loaded THEN Loading state`() = runTest { // Arrange — the interactor has not emitted yet (books still syncing). - every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns emptyFlow() + every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns emptyFlow() // Act val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) @@ -73,7 +73,7 @@ internal class AddressBookListModelTest { @Test fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest { // Arrange - every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns + every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob"))) // Act @@ -89,7 +89,7 @@ internal class AddressBookListModelTest { @Test fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest { // Arrange - every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns flowOf(emptyList()) + every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(emptyList()) // Act val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) @@ -103,7 +103,7 @@ internal class AddressBookListModelTest { fun `GIVEN default mode WHEN contact clicked THEN editor opened with contact id`() = runTest { // Arrange var clickedId: String? = null - every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns + every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(listOf(verifiedContact(id = "42", name = "Alice"))) val model = createModel( testScope = this, From fa433ef8d18ffbe719a36b7c54788068e456a1af Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 14:41:04 +0100 Subject: [PATCH 2/2] Updated on 2026-08-14 --- .../features/addressbook/editcontact/model/EditContactModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index bbc1caed26..b6ff6cdd5c 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -232,6 +232,7 @@ internal class EditContactModel @Inject constructor( selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(), ) { name, walletId -> name to walletId } .mapLatest { (name, walletId) -> validateName(name, walletId) } + .flowOn(dispatchers.default) .onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) } .launchIn(modelScope) }