Updated on 2026-08-14
This commit is contained in:
parent
bee4019e8b
commit
68f451d926
8 changed files with 253 additions and 358 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<VerifiedContact>> {
|
||||
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<VerifyMessagesError, AddressEntriesVerification> {
|
||||
|
|
@ -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] }
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<VerifiedContact>> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
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<List<ByteArray>>()
|
||||
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
|
||||
}
|
||||
|
|
@ -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<AddressEntry>): 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
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<List<ByteArray>>()
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue