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 5535d25f92..07e8299d97 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 @@ -1,18 +1,17 @@ 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.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.GetVerifiedContactsUseCase 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.addressbook.usecase.VerifyAddressEntriesUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.transaction.usecase.SignUseCase @@ -40,14 +39,6 @@ object AddressBookDomainModule { ) } - @Provides - @Singleton - fun provideVerifyAddressEntriesUseCase( - verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, - ): VerifyAddressEntriesUseCase { - return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) - } - @Provides @Singleton fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { @@ -68,14 +59,14 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideGetVerifiedContactsUseCase( + fun provideGetVerifiedContactsInteractor( getContactsUseCase: GetContactsUseCase, - verifyAddressEntriesUseCase: VerifyAddressEntriesUseCase, + verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, userWalletsListRepository: UserWalletsListRepository, - ): GetVerifiedContactsUseCase { - return GetVerifiedContactsUseCase( + ): GetVerifiedContactsInteractor { + return GetVerifiedContactsInteractor( getContacts = getContactsUseCase, - verifyAddressEntries = verifyAddressEntriesUseCase, + verifyMessages = verifyMessagesUseCase, userWalletsListRepository = userWalletsListRepository, ) } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt similarity index 50% rename from domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt rename to domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt index d82ecd9ee4..e008d841e5 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt @@ -1,32 +1,42 @@ -package com.tangem.domain.addressbook.usecase +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.AddressEntry 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.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 -/** - * Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones - * whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that - * were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase]. - * - * An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex); - * such entries should be hidden from the user. Both partitions preserve the contact's original entry - * order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces - * as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all). - * - * Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]). - */ -class VerifyAddressEntriesUseCase( - private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, +class GetVerifiedContactsInteractor( + private val getContacts: GetContactsUseCase, + private val verifyMessages: VerifySecp256k1MessagesUseCase, + private val userWalletsListRepository: UserWalletsListRepository, ) { - operator fun invoke( + operator fun invoke(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(addressEntries = verification.valid), + invalidEntries = verification.invalid, + ) + } + } + } + + private fun verify( userWallet: UserWallet, contact: Contact, ): Either { @@ -40,7 +50,7 @@ class VerifyAddressEntriesUseCase( val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) } val signatures = wellFormed.map { (_, signature) -> signature } - return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures) + return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures) .map { flags -> val validIds = wellFormed .filterIndexed { index, _ -> flags[index] } 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 4a2cde23e9..fedf05098a 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 [VerifyAddressEntriesUseCase] + * Shared by [SignAddressEntriesUseCase] (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/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt index af19f1f2dc..acf8409dc1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -18,15 +18,14 @@ class GetContactsUseCase( } val normalizedQuery = query.trim() if (normalizedQuery.isEmpty()) return source - return source - .map { contacts -> - contacts.filter { contact -> - val isNameContaining = contact.name.value.contains(other = normalizedQuery, ignoreCase = true) - val isAddressContaining = contact.addressEntries.any { addressEntry -> - addressEntry.address.contains(other = normalizedQuery, ignoreCase = true) - } - isNameContaining || isAddressContaining - } - } + return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } } + } + + private fun Contact.matches(query: String): Boolean { + val isNameContaining = name.value.contains(other = query, ignoreCase = true) + val isAddressContaining = addressEntries.any { addressEntry -> + addressEntry.address.contains(other = query, ignoreCase = true) + } + return isNameContaining || isAddressContaining } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt deleted file mode 100644 index be933b28e7..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import com.tangem.domain.addressbook.model.VerifiedContact -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetVerifiedContactsUseCase( - private val getContacts: GetContactsUseCase, - private val verifyAddressEntries: VerifyAddressEntriesUseCase, - private val userWalletsListRepository: UserWalletsListRepository, -) { - - operator fun invoke(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 = verifyAddressEntries(userWallet, contact).getOrNull() ?: return@mapNotNull null - VerifiedContact( - contact = contact.copy(addressEntries = verification.valid), - invalidEntries = verification.invalid, - ) - } - } - } -} \ 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 new file mode 100644 index 0000000000..f0d6b4524f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt @@ -0,0 +1,209 @@ +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 +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.usecase.GetContactsUseCase +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.flow.first +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 GetVerifiedContactsInteractorTest { + + private val getContacts: GetContactsUseCase = mockk() + private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val interactor = GetVerifiedContactsInteractor( + getContacts = getContacts, + verifyMessages = verifyMessages, + userWalletsListRepository = userWalletsListRepository, + ) + + 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) + } + + @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(addressEntries = 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.addressEntries[0]), + expectedPayload(contact, contact.addressEntries[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.addressEntries).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.addressEntries).containsExactly(signed) + assertThat(result.invalidEntries).containsExactly(malformed) + } + + @Test + fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest { + // Arrange + val contact = contact() + stubContacts(contact) + + // Act + val result = interactor(query = "").first().single() + + // Assert + assertThat(result.contact.addressEntries).isEmpty() + assertThat(result.invalidEntries).isEmpty() + verify(exactly = 0) { verifyMessages(any(), any(), any()) } + } + + @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( + 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", + addressEntries = 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/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt deleted file mode 100644 index 9ea96b80b0..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt +++ /dev/null @@ -1,113 +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.model.* -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 io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.first -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 GetVerifiedContactsUseCaseTest { - - private val getContacts: GetContactsUseCase = mockk() - private val verifyAddressEntries: VerifyAddressEntriesUseCase = mockk() - private val userWalletsListRepository: UserWalletsListRepository = mockk() - - private val useCase = GetVerifiedContactsUseCase( - getContacts = getContacts, - verifyAddressEntries = verifyAddressEntries, - userWalletsListRepository = userWalletsListRepository, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsUseCaseTest.walletId } - - private val validEntry = entry(id = "valid", address = "0xvalid") - private val invalidEntry = entry(id = "invalid", address = "0xinvalid") - private val contact = contact(name = "Alice", entries = listOf(validEntry, invalidEntry)) - - @BeforeEach - fun resetMocks() { - clearMocks(getContacts, verifyAddressEntries, userWalletsListRepository) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - } - - @Test - fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { - // Arrange - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - every { verifyAddressEntries(userWallet, contact) } returns - AddressEntriesVerification(valid = listOf(validEntry), invalid = listOf(invalidEntry)).right() - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).containsExactly( - VerifiedContact( - contact = contact.copy(addressEntries = listOf(validEntry)), - invalidEntries = listOf(invalidEntry), - ), - ) - } - - @Test - fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - every { verifyAddressEntries(userWallet, contact) } returns VerifyMessagesError.NoSigningKey.left() - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - private fun entry(id: String, address: String): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = null, - signature = "sig-$id", - networkName = "Ethereum", - ) - - private fun contact(name: String, entries: List): 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", - addressEntries = entries, - ) -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt deleted file mode 100644 index 04821e35ac..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ /dev/null @@ -1,173 +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.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.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase -import com.tangem.utils.extensions.toHexString -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class VerifyAddressEntriesUseCaseTest { - - private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk() - private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) - - private val userWallet: UserWallet = mockk() - - @BeforeEach - fun resetMocks() { - clearMocks(verifyMessagesUseCase) - } - - @Test - fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() { - // 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 { - verifyMessagesUseCase(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) - } returns listOf(true, true).right() - - // Act - val result = useCase(userWallet, contact) - - // Assert - // Each entry is verified against address + networkId + memo + contactId + name - assertThat(messagesSlot.captured.map { String(it) }) - .containsExactly( - expectedPayload(contact, contact.addressEntries[0]), - expectedPayload(contact, contact.addressEntries[1]), - ) - .inOrder() - // Hex signatures are decoded to bytes, in entry order - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() - } - - @Test - fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() { - // 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 { verifyMessagesUseCase(any(), any(), any()) } returns listOf(true, false, true).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).containsExactly(valid1, valid2).inOrder() - assertThat(result.invalid).containsExactly(invalid) - assertThat(result.areAllInvalid).isFalse() - } - - @Test - fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() { - // 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 { - verifyMessagesUseCase(eq(userWallet), any(), capture(signaturesSlot)) - } returns listOf(true).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - // Only the well-formed entry is passed to verification - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") - assertThat(result!!.valid).containsExactly(signed) - assertThat(result.invalid).containsExactly(malformed) - } - - @Test - fun `GIVEN every entry is invalid WHEN invoke THEN allInvalid is true`() { - // Arrange - val entry1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") - val entry2 = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") - val contact = contact(entry1, entry2) - every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(false, false).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).isEmpty() - assertThat(result.invalid).containsExactly(entry1, entry2).inOrder() - assertThat(result.areAllInvalid).isTrue() - } - - @Test - fun `GIVEN contact with no entries WHEN invoke THEN returns empty partition without verifying`() { - // Arrange - val contact = contact() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).isEmpty() - assertThat(result.invalid).isEmpty() - assertThat(result.areAllInvalid).isFalse() - verify(exactly = 0) { verifyMessagesUseCase(any(), any(), any()) } - } - - @Test - fun `GIVEN verifyMessagesUseCase returns error WHEN invoke THEN propagates the error`() { - // Arrange - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) - every { verifyMessagesUseCase(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey) - } - - 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?, signature: String): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = memo, - signature = signature, - networkName = "Ethereum", - ) - - 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