Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 15:43:11 +01:00
commit 31a8ee5dd4
18 changed files with 579 additions and 366 deletions

View file

@ -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<List<VerifiedContact>> {
fun getVerifiedContacts(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(addresses = verification.valid),
invalidEntries = verification.invalid,
)
}
contactSignatureVerifier.verifyContacts(contacts)
}
}
private fun verify(
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
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 },
)
}
}
}

View file

@ -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<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName(userWalletId, name)
val validName = validateContactName.validate(userWalletId, name)
.mapLeft(SaveContactError::Name)
.bind()

View file

@ -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

View file

@ -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<ContactNameValidationError, ContactName> = 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
}
}

View file

@ -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<ContactNameValidationError, ContactName> =
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
}
}

View file

@ -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<Contact>): List<VerifiedContact> {
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<VerifyMessagesError, AddressEntriesVerification> {
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 },
)
}
}
}

View file

@ -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<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.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<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.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
}

View file

@ -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<Contact>()
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<UserWalletId>()) }
coVerify(exactly = 0) { contactNameValidator.validate(any(), any()) }
}
@Test

View file

@ -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",
),
),
)
}

View file

@ -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",
),
),
)
}

View file

@ -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<List<ByteArray>>()
val signaturesSlot = slot<List<ByteArray>>()
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<List<ByteArray>>()
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
}