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

@ -12,7 +12,8 @@ import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.SignUseCase
@ -42,8 +43,26 @@ object AddressBookDomainModule {
@Provides
@Singleton
fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase {
return ValidateContactNameUseCase(repository = repository)
fun provideContactSignatureVerifier(
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
userWalletsListRepository: UserWalletsListRepository,
): ContactSignatureVerifier {
return ContactSignatureVerifier(
verifyMessages = verifyMessagesUseCase,
userWalletsListRepository = userWalletsListRepository,
)
}
@Provides
@Singleton
fun provideContactNameValidator(
repository: AddressBookRepository,
contactSignatureVerifier: ContactSignatureVerifier,
): ContactNameValidator {
return ContactNameValidator(
repository = repository,
contactSignatureVerifier = contactSignatureVerifier,
)
}
@Provides
@ -56,13 +75,11 @@ object AddressBookDomainModule {
@Singleton
fun provideGetVerifiedContactsInteractor(
getContactsUseCase: GetContactsUseCase,
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
userWalletsListRepository: UserWalletsListRepository,
contactSignatureVerifier: ContactSignatureVerifier,
): GetVerifiedContactsInteractor {
return GetVerifiedContactsInteractor(
getContacts = getContactsUseCase,
verifyMessages = verifyMessagesUseCase,
userWalletsListRepository = userWalletsListRepository,
contactSignatureVerifier = contactSignatureVerifier,
)
}
@ -70,13 +87,13 @@ object AddressBookDomainModule {
@Singleton
fun provideSaveContactInteractor(
repository: AddressBookRepository,
validateContactNameUseCase: ValidateContactNameUseCase,
contactNameValidator: ContactNameValidator,
signUseCase: SignUseCase,
timestampProvider: IsoTimestampProvider,
): SaveContactInteractor {
return SaveContactInteractor(
repository = repository,
validateContactName = validateContactNameUseCase,
validateContactName = contactNameValidator,
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)

View file

@ -2275,6 +2275,8 @@
<string name="warning_hedera_missing_token_association_message_brief">このトークンを受け取るには、Hederaアカウントに関連付ける必要があります。</string>
<string name="warning_hedera_missing_token_association_title">トークンを関連付ける</string>
<string name="warning_hedera_token_association_not_enough_hbar_message">%sが不足しています。このトークンを関連付けるには、Hederaアカウントに資金を追加してください。</string>
<string name="warning_incomplete_backup_notification_message">カードの有効化が正常に完了していないことが判明しました。原因として、お使いの端末のNFC機能の不具合、またはカードをスマートフォンに正しくかざせていない可能性があります。詳しくはサポートまでお問い合わせください。</string>
<string name="warning_incomplete_backup_notification_title">対応が必要です。ウォレットは使用しないでください。</string>
<string name="warning_kaspa_unfinished_token_transaction_discard_message">取引をキャンセルしてもよろしいですか? 取引を再試行することはできません</string>
<string name="warning_kaspa_unfinished_token_transaction_message">%1$s %2$sの取引が完了しませんでした。もう一度試して完了してください。</string>
<string name="warning_kaspa_unfinished_token_transaction_title">未完了の取引があります</string>

View file

@ -108,7 +108,7 @@
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string>
<string name="address_book_create_success_message">Contact added</string>
<string name="address_book_create_success_message">Contact saved</string>
<string name="address_book_creating_error">Couldn\'t create contact. Please try again later.</string>
<string name="address_book_delete_contact">Delete contact</string>
<string name="address_book_delete_contact_description">This contact will be deleted from all your address books</string>
@ -1285,12 +1285,14 @@
<string name="onramp_title_available_from">Available from</string>
<string name="onramp_title_available_up_to">Available up to</string>
<string name="onramp_title_you_get">You get</string>
<string name="onramp_token_is_not_supported_banner_subtitle">This token is not supported. Please choose a different token to buy.</string>
<string name="onramp_tos_external_providers">Service is provided by an external provider. \nTangem is not responsible.</string>
<string name="onramp_transaction_status_footer_text">You can close this screen and check the transaction status on the token details screen.</string>
<string name="onramp_up_to_rate">Up to</string>
<string name="onramp_via">Via</string>
<string name="onramp_via_provider">via %s</string>
<string name="onramp_you_will_pay_title">You will pay</string>
<string name="onramps_token_is_not_supported_banner_title">%s is not supported</string>
<string name="organize_tokens_group">Group</string>
<string name="organize_tokens_menu_group_by_networks">Group by networks</string>
<string name="organize_tokens_menu_sort_by_balance">Sort by balance</string>
@ -2077,6 +2079,9 @@
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
<string name="token_market_metrics_no_data">N/A</string>
<string name="token_receive_show_qr_code_title">Show QR code</string>
<string name="token_summary_can_not_load_token">Cant load data of the token</string>
<string name="token_summary_go_to_swap_button">Go to swap</string>
<string name="token_summary_title">Token summary</string>
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Swap with Changelly, %s fees</string>
<string name="token_swap_promotion_button">Swap now</string>

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
}

View file

@ -20,7 +20,7 @@ import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
@ -55,7 +55,7 @@ internal class EditContactModel @Inject constructor(
private val resultHolder: AddressBookResultHolder,
private val messageSender: UiMessageSender,
private val userWalletsListRepository: UserWalletsListRepository,
private val validateContactNameUseCase: ValidateContactNameUseCase,
private val contactNameValidator: ContactNameValidator,
private val saveContactInteractor: SaveContactInteractor,
private val getContactByIdUseCase: GetContactByIdUseCase,
private val deleteContactUseCase: DeleteContactUseCase,
@ -232,6 +232,7 @@ internal class EditContactModel @Inject constructor(
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
) { name, walletId -> name to walletId }
.mapLatest { (name, walletId) -> validateName(name, walletId) }
.flowOn(dispatchers.default)
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
.launchIn(modelScope)
}
@ -417,7 +418,7 @@ internal class EditContactModel @Inject constructor(
private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? {
if (name.isBlank()) return null
if (name == loadedContact.value?.name?.value) return null
val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return ContactNameErrorConverter().convert(error)
}

View file

@ -58,12 +58,16 @@ internal class AddressBookListModel @Inject constructor(
private val selectedWalletId = MutableStateFlow<String?>(value = null)
private val allContacts: SharedFlow<List<VerifiedContact>> =
getVerifiedContactsInteractor(query = "", userWalletId = null)
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init {
val matchedContacts = searchQuery.flatMapLatest { query ->
if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null)
if (query.isBlank()) {
allContacts
} else {
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
}
}
combine(
allContacts,

View file

@ -19,7 +19,7 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.CryptoPortfolioIcon
@ -56,7 +56,7 @@ internal class EditContactModelTest {
private val resultHolder = AddressBookResultHolder()
private val messageSender: UiMessageSender = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val validateContactNameUseCase: ValidateContactNameUseCase = mockk()
private val contactNameValidator: ContactNameValidator = mockk()
private val saveContactInteractor: SaveContactInteractor = mockk()
private val getContactByIdUseCase: GetContactByIdUseCase = mockk()
private val deleteContactUseCase: DeleteContactUseCase = mockk()
@ -75,7 +75,7 @@ internal class EditContactModelTest {
fun setUp() {
// Default: no wallets loaded, name always valid. Individual tests override as needed.
setupWallets(wallets = emptyList(), selected = null)
coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right()
coEvery { contactNameValidator.validate(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right()
every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher
every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData
// No existing contact by default; a StateFlow<null> never surfaces a contact and never completes.
@ -396,7 +396,7 @@ internal class EditContactModelTest {
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery {
validateContactNameUseCase(any(), any())
contactNameValidator.validate(any(), any())
} returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
@ -434,7 +434,7 @@ internal class EditContactModelTest {
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
coEvery {
validateContactNameUseCase(walletB.walletId, "Satoshi")
contactNameValidator.validate(walletB.walletId, "Satoshi")
} returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
@ -489,7 +489,7 @@ internal class EditContactModelTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { validateContactNameUseCase(any(), any()) } returns ContactNameValidationError.Duplicate.left()
coEvery { contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
@ -927,7 +927,7 @@ internal class EditContactModelTest {
resultHolder = resultHolder,
messageSender = messageSender,
userWalletsListRepository = userWalletsListRepository,
validateContactNameUseCase = validateContactNameUseCase,
contactNameValidator = contactNameValidator,
saveContactInteractor = saveContactInteractor,
getContactByIdUseCase = getContactByIdUseCase,
deleteContactUseCase = deleteContactUseCase,

View file

@ -61,7 +61,7 @@ internal class AddressBookListModelTest {
@Test
fun `GIVEN feature just opened WHEN contacts not yet loaded THEN Loading state`() = runTest {
// Arrange — the interactor has not emitted yet (books still syncing).
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns emptyFlow()
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns emptyFlow()
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
@ -73,7 +73,7 @@ internal class AddressBookListModelTest {
@Test
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
// Arrange
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
// Act
@ -89,7 +89,7 @@ internal class AddressBookListModelTest {
@Test
fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest {
// Arrange
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns flowOf(emptyList())
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(emptyList())
// Act
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
@ -103,7 +103,7 @@ internal class AddressBookListModelTest {
fun `GIVEN default mode WHEN contact clicked THEN editor opened with contact id`() = runTest {
// Arrange
var clickedId: String? = null
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
val model = createModel(
testScope = this,