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 a36d46d2f8..607b37e89b 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 @@ -92,14 +92,26 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideGetContactByIdUseCase(repository: AddressBookRepository): GetContactByIdUseCase { - return GetContactByIdUseCase(repository = repository) + fun provideGetContactByIdUseCase( + repository: AddressBookRepository, + contactSignatureVerifier: ContactSignatureVerifier, + ): GetContactByIdUseCase { + return GetContactByIdUseCase( + repository = repository, + contactSignatureVerifier = contactSignatureVerifier, + ) } @Provides @Singleton - fun provideCheckAddressDuplicateUseCase(repository: AddressBookRepository): CheckAddressDuplicateUseCase { - return CheckAddressDuplicateUseCase(repository = repository) + fun provideCheckAddressDuplicateUseCase( + repository: AddressBookRepository, + contactSignatureVerifier: ContactSignatureVerifier, + ): CheckAddressDuplicateUseCase { + return CheckAddressDuplicateUseCase( + repository = repository, + contactSignatureVerifier = contactSignatureVerifier, + ) } @Provides diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt index 5cebef3fe4..ba9494ac5e 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.addressbook.usecase import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.models.wallet.UserWalletId /** @@ -13,11 +14,12 @@ import com.tangem.domain.models.wallet.UserWalletId * contact currently being edited. Address comparison is exact (matching the in-editor dedup in * `AddValidatedAddressTransformer`), so case-sensitive chains are not falsely flagged. * - * Reads the local snapshot ([AddressBookRepository.getContactsSync]) rather than the syncing flow, so validating on - * every keystroke/selection change never triggers a backend sync. + * Only verified addresses count as duplicates: contacts are run through [ContactSignatureVerifier] first, so + * an unverified/invalid entry never blocks the pair and the user is free to overwrite it. */ class CheckAddressDuplicateUseCase( private val repository: AddressBookRepository, + private val contactSignatureVerifier: ContactSignatureVerifier, ) { suspend operator fun invoke( @@ -27,7 +29,8 @@ class CheckAddressDuplicateUseCase( excludeContactId: ContactId? = null, ): String? { val contacts = repository.getContactsSync(userWalletId) - return contacts + return contactSignatureVerifier.verifyContacts(contacts) + .map { it.contact } .firstOrNull { contact -> contact.id != excludeContactId && contact.addresses.any { entry -> entry.networkId.value == networkId && entry.address == address diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt index c204434299..085f3654d1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt @@ -3,13 +3,26 @@ package com.tangem.domain.addressbook.usecase 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.verification.ContactSignatureVerifier import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +/** + * Emits the contact with the given [id], carrying only its verified addresses. Entries whose signatures + * don't verify against the wallet are stripped, and a contact left with no verified addresses is treated + * as absent (`null`) — the UI must never surface unverified addresses. + */ class GetContactByIdUseCase( private val repository: AddressBookRepository, + private val contactSignatureVerifier: ContactSignatureVerifier, ) { - operator fun invoke(id: ContactId): Flow = - repository.getAllContacts().map { contacts -> contacts.find { it.id == id } } + operator fun invoke(id: ContactId): Flow { + return repository.getAllContacts().map { contacts -> + val contact = contacts.find { it.id == id } ?: return@map null + contactSignatureVerifier.verifyContacts(listOf(contact)) + .firstOrNull() + ?.contact + } + } } \ 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 index 18a09bab59..b03872db97 100644 --- 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 @@ -19,14 +19,18 @@ class ContactSignatureVerifier( 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, - ) - } + 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, + ) + } + .filter { verifiedContact -> + verifiedContact.contact.addresses.isNotEmpty() + } } suspend fun isNameVerified(contact: Contact): Boolean { diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt index d1ccfe1602..4feaffe28d 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt @@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.model.* 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 @@ -17,13 +18,18 @@ import org.junit.jupiter.api.TestInstance class CheckAddressDuplicateUseCaseTest { private val repository: AddressBookRepository = mockk() - private val useCase = CheckAddressDuplicateUseCase(repository) + private val contactSignatureVerifier: ContactSignatureVerifier = mockk() + private val useCase = CheckAddressDuplicateUseCase(repository, contactSignatureVerifier) private val walletId = UserWalletId("0001") @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, contactSignatureVerifier) + // Default: every stored address verifies, so the use case sees the contacts unchanged. + coEvery { contactSignatureVerifier.verifyContacts(any()) } answers { + firstArg>().map { VerifiedContact(contact = it, invalidEntries = emptyList()) } + } } @Test @@ -75,6 +81,21 @@ class CheckAddressDuplicateUseCaseTest { assertThat(result).isNull() } + @Test + fun `GIVEN the pair belongs only to an unverified entry WHEN invoke THEN returns null`() = runTest { + // Arrange + val contact = contact("Binance", "0xAAA", ETHEREUM) + coEvery { repository.getContactsSync(walletId) } returns listOf(contact) + // Verification strips the unverified address, so the pair is no longer held by any contact. + coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns emptyList() + + // Act + val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA") + + // Assert + assertThat(result).isNull() + } + private fun contact(name: String, address: String, networkId: String, id: String = "id-$name"): Contact = Contact( id = ContactId(id), walletId = walletId, diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt index 2c6ab84e6a..888e995a93 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt @@ -1,12 +1,19 @@ package com.tangem.domain.addressbook.usecase 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.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.every import io.mockk.mockk import kotlinx.coroutines.flow.first @@ -20,28 +27,48 @@ import org.junit.jupiter.api.TestInstance class GetContactByIdUseCaseTest { private val repository: AddressBookRepository = mockk() - private val useCase = GetContactByIdUseCase(repository) + private val contactSignatureVerifier: ContactSignatureVerifier = mockk() + private val useCase = GetContactByIdUseCase(repository, contactSignatureVerifier) @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, contactSignatureVerifier) } @Test - fun `GIVEN matching id WHEN invoke THEN emits that contact`() = runTest { + fun `GIVEN matching id WHEN invoke THEN emits verified contact with only valid addresses`() = runTest { // Arrange - val target = contact("id-2", "Bob") - every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), target)) + val valid = entry("addr-valid") + val invalid = entry("addr-invalid") + val stored = contact("id-2", "Bob", valid, invalid) + val verified = stored.copy(addresses = listOf(valid)) + every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), stored)) + coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns + listOf(VerifiedContact(contact = verified, invalidEntries = listOf(invalid))) // Act val result = useCase(ContactId("id-2")).first() // Assert - assertThat(result).isEqualTo(target) + assertThat(result).isEqualTo(verified) } @Test - fun `GIVEN no matching id WHEN invoke THEN emits null`() = runTest { + fun `GIVEN contact has no verified addresses WHEN invoke THEN emits null`() = runTest { + // Arrange + val stored = contact("id-2", "Bob", entry("addr-invalid")) + every { repository.getAllContacts() } returns flowOf(listOf(stored)) + coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns emptyList() + + // Act + val result = useCase(ContactId("id-2")).first() + + // Assert + assertThat(result).isNull() + } + + @Test + fun `GIVEN no matching id WHEN invoke THEN emits null without verifying`() = runTest { // Arrange every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"))) @@ -50,9 +77,10 @@ class GetContactByIdUseCaseTest { // Assert assertThat(result).isNull() + coVerify(exactly = 0) { contactSignatureVerifier.verifyContacts(any()) } } - private fun contact(id: String, name: String): Contact = Contact( + private fun contact(id: String, name: String, vararg addresses: AddressEntry): Contact = Contact( id = ContactId(id), walletId = UserWalletId("0001"), name = requireNotNull(ContactName(name).getOrNull()), @@ -60,6 +88,14 @@ class GetContactByIdUseCaseTest { iconColor = "Azure", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", - addresses = emptyList(), + addresses = addresses.toList(), + ) + + private fun entry(id: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = "0x$id", + networkId = Network.RawID("ethereum"), + memo = null, + signature = "AABB", ) } \ 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 index 4f7dd9705b..c7f9b05a90 100644 --- 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 @@ -140,19 +140,73 @@ class ContactSignatureVerifierTest { } @Test - fun `GIVEN contact with no entries WHEN verifyContacts THEN keeps contact without verifying`() = runTest { + fun `GIVEN contact with no entries WHEN verifyContacts THEN contact is dropped without verifying`() = runTest { // Arrange val contact = contact() // Act - val result = verifier.verifyContacts(listOf(contact)).single() + val result = verifier.verifyContacts(listOf(contact)) // Assert - assertThat(result.contact.addresses).isEmpty() - assertThat(result.invalidEntries).isEmpty() + assertThat(result).isEmpty() verify(exactly = 0) { verifyMessages(any(), any(), any()) } } + @Test + fun `GIVEN all entries invalid WHEN verifyContacts THEN contact is dropped`() = 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, false).right() + + // Act + val result = verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN entry with malformed signature only WHEN verifyContacts THEN contact is dropped`() = runTest { + // Arrange + val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") + val contact = contact(malformed) + every { verifyMessages(any(), any(), any()) } returns emptyList().right() + + // Act + val result = verifier.verifyContacts(listOf(contact)) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN one contact fully invalid AND another valid WHEN verifyContacts THEN only the valid one is kept`() = + runTest { + // Arrange + val invalidEntry = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") + val validEntry = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") + val droppedContact = contact(invalidEntry).copy(id = ContactId("contact-dropped")) + val keptContact = contact(validEntry).copy(id = ContactId("contact-kept")) + every { verifyMessages(any(), any(), any()) } returnsMany listOf( + listOf(false).right(), + listOf(true).right(), + ) + + // Act + val result = verifier.verifyContacts(listOf(droppedContact, keptContact)) + + // Assert + assertThat(result).containsExactly( + VerifiedContact( + contact = keptContact.copy(addresses = listOf(validEntry)), + invalidEntries = emptyList(), + ), + ) + } + @Test fun `GIVEN wallet cannot be resolved WHEN verifyContacts THEN contact is dropped`() = runTest { // Arrange