Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 10:27:20 +01:00
parent 6256f4ed23
commit 68297064be
19 changed files with 80 additions and 158 deletions

View file

@ -1,6 +1,6 @@
package com.tangem.domain.addressbook.interactor package com.tangem.domain.addressbook.interactor
import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
@ -12,7 +12,7 @@ class GetVerifiedContactsInteractor(
private val contactSignatureVerifier: ContactSignatureVerifier, private val contactSignatureVerifier: ContactSignatureVerifier,
) { ) {
fun getVerifiedContacts(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> { fun getVerifiedContacts(query: String, userWalletId: UserWalletId? = null): Flow<List<Contact>> {
return getContacts(query, userWalletId).map { contacts -> return getContacts(query, userWalletId).map { contacts ->
contactSignatureVerifier.verifyContacts(contacts) contactSignatureVerifier.verifyContacts(contacts)
} }

View file

@ -1,15 +0,0 @@
package com.tangem.domain.addressbook.model
/**
* Outcome of verifying a [Contact]'s [AddressEntry]s against the wallet that signed them.
*
* @property valid entries whose signature was produced by the wallet these should be shown.
* @property invalid entries that failed verification (tampered, signed by another wallet, or carrying
* a missing/malformed signature) these should be hidden.
*/
data class AddressEntriesVerification(
val valid: List<AddressEntry>,
val invalid: List<AddressEntry>,
) {
val areAllInvalid: Boolean get() = valid.isEmpty() && invalid.isNotEmpty()
}

View file

@ -1,12 +0,0 @@
package com.tangem.domain.addressbook.model
/**
* @property contact the contact carrying only the entries whose signatures verified against the
* wallet what should be shown to the user.
* @property invalidEntries entries that failed verification (tampered, signed by another wallet, or
* malformed). Hidden from the UI but kept for analytics.
*/
data class VerifiedContact(
val contact: Contact,
val invalidEntries: List<AddressEntry>,
)

View file

@ -30,7 +30,6 @@ class CheckAddressDuplicateUseCase(
): String? { ): String? {
val contacts = repository.getContactsSync(userWalletId) val contacts = repository.getContactsSync(userWalletId)
return contactSignatureVerifier.verifyContacts(contacts) return contactSignatureVerifier.verifyContacts(contacts)
.map { it.contact }
.firstOrNull { contact -> .firstOrNull { contact ->
contact.id != excludeContactId && contact.addresses.any { entry -> contact.id != excludeContactId && contact.addresses.any { entry ->
entry.networkId.value == networkId && entry.address == address entry.networkId.value == networkId && entry.address == address

View file

@ -20,9 +20,7 @@ class GetContactByIdUseCase(
operator fun invoke(id: ContactId): Flow<Contact?> { operator fun invoke(id: ContactId): Flow<Contact?> {
return repository.getAllContacts().map { contacts -> return repository.getAllContacts().map { contacts ->
val contact = contacts.find { it.id == id } ?: return@map null val contact = contacts.find { it.id == id } ?: return@map null
contactSignatureVerifier.verifyContacts(listOf(contact)) contactSignatureVerifier.verifyContacts(listOf(contact)).firstOrNull()
.firstOrNull()
?.contact
} }
} }
} }

View file

@ -2,9 +2,8 @@ package com.tangem.domain.addressbook.verification
import arrow.core.Either import arrow.core.Either
import arrow.core.right import arrow.core.right
import com.tangem.domain.addressbook.model.AddressEntriesVerification import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
@ -17,34 +16,24 @@ class ContactSignatureVerifier(
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
) { ) {
suspend fun verifyContacts(contacts: List<Contact>): List<VerifiedContact> { suspend fun verifyContacts(contacts: List<Contact>): List<Contact> {
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
return contacts return contacts.mapNotNull { contact ->
.mapNotNull { contact -> val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null val validEntries = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null contact.copy(addresses = validEntries).takeIf { validEntries.isNotEmpty() }
VerifiedContact( }
contact = contact.copy(addresses = verification.valid),
invalidEntries = verification.invalid,
)
}
.filter { verifiedContact ->
verifiedContact.contact.addresses.isNotEmpty()
}
} }
suspend fun isNameVerified(contact: Contact): Boolean { suspend fun isNameVerified(contact: Contact): Boolean {
val userWallet = userWalletsListRepository.userWalletsSync() val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it.walletId == contact.walletId } ?: return false .firstOrNull { it.walletId == contact.walletId } ?: return false
return verify(userWallet, contact).getOrNull()?.valid?.isNotEmpty() == true return verify(userWallet, contact).getOrNull()?.isNotEmpty() == true
} }
private fun verify( private fun verify(userWallet: UserWallet, contact: Contact): Either<VerifyMessagesError, List<AddressEntry>> {
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
val entries = contact.addresses val entries = contact.addresses
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right() if (entries.isEmpty()) return emptyList<AddressEntry>().right()
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format. // Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
val wellFormed = entries.mapNotNull { entry -> val wellFormed = entries.mapNotNull { entry ->
@ -59,10 +48,7 @@ class ContactSignatureVerifier(
.filterIndexed { index, _ -> flags[index] } .filterIndexed { index, _ -> flags[index] }
.mapTo(HashSet()) { (entry, _) -> entry.id } .mapTo(HashSet()) { (entry, _) -> entry.id }
AddressEntriesVerification( entries.filter { it.id in validIds }
valid = entries.filter { it.id in validIds },
invalid = entries.filterNot { it.id in validIds },
)
} }
} }
} }

View file

@ -6,7 +6,6 @@ import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName 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.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
@ -45,7 +44,7 @@ class GetVerifiedContactsInteractorTest {
fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest { fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest {
// Arrange // Arrange
val contact = contact() val contact = contact()
val verified = VerifiedContact(contact = contact, invalidEntries = emptyList()) val verified = contact.copy(addresses = emptyList())
every { getContacts(query = "query", userWalletId = walletId) } returns flowOf(listOf(contact)) every { getContacts(query = "query", userWalletId = walletId) } returns flowOf(listOf(contact))
coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns listOf(verified) coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns listOf(verified)

View file

@ -27,9 +27,7 @@ class CheckAddressDuplicateUseCaseTest {
fun resetMocks() { fun resetMocks() {
clearMocks(repository, contactSignatureVerifier) clearMocks(repository, contactSignatureVerifier)
// Default: every stored address verifies, so the use case sees the contacts unchanged. // Default: every stored address verifies, so the use case sees the contacts unchanged.
coEvery { contactSignatureVerifier.verifyContacts(any()) } answers { coEvery { contactSignatureVerifier.verifyContacts(any()) } answers { firstArg<List<Contact>>() }
firstArg<List<Contact>>().map { VerifiedContact(contact = it, invalidEntries = emptyList()) }
}
} }
@Test @Test

View file

@ -6,7 +6,6 @@ import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
@ -43,8 +42,7 @@ class GetContactByIdUseCaseTest {
val stored = contact("id-2", "Bob", valid, invalid) val stored = contact("id-2", "Bob", valid, invalid)
val verified = stored.copy(addresses = listOf(valid)) val verified = stored.copy(addresses = listOf(valid))
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), stored)) every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), stored))
coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns listOf(verified)
listOf(VerifiedContact(contact = verified, invalidEntries = listOf(invalid)))
// Act // Act
val result = useCase(ContactId("id-2")).first() val result = useCase(ContactId("id-2")).first()

View file

@ -8,7 +8,6 @@ import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
@ -53,7 +52,7 @@ class ContactSignatureVerifierTest {
inner class VerifyContacts { inner class VerifyContacts {
@Test @Test
fun `GIVEN mixed entries WHEN verifyContacts THEN displays only valid AND keeps invalid for analytics`() = fun `GIVEN mixed entries WHEN verifyContacts THEN keeps only the valid ones`() =
runTest { runTest {
// Arrange // Arrange
val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB") val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB")
@ -65,12 +64,7 @@ class ContactSignatureVerifierTest {
val result = verifier.verifyContacts(listOf(contact)) val result = verifier.verifyContacts(listOf(contact))
// Assert // Assert
assertThat(result).containsExactly( assertThat(result).containsExactly(contact.copy(addresses = listOf(valid)))
VerifiedContact(
contact = contact.copy(addresses = listOf(valid)),
invalidEntries = listOf(invalid),
),
)
} }
@Test @Test
@ -101,7 +95,7 @@ class ContactSignatureVerifierTest {
} }
@Test @Test
fun `GIVEN some entries fail verification WHEN verifyContacts THEN partitions them preserving order`() = fun `GIVEN some entries fail verification WHEN verifyContacts THEN keeps valid ones preserving order`() =
runTest { runTest {
// Arrange // Arrange
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
@ -114,8 +108,7 @@ class ContactSignatureVerifierTest {
val result = verifier.verifyContacts(listOf(contact)).single() val result = verifier.verifyContacts(listOf(contact)).single()
// Assert // Assert
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder() assertThat(result.addresses).containsExactly(valid1, valid2).inOrder()
assertThat(result.invalidEntries).containsExactly(invalid)
} }
@Test @Test
@ -135,8 +128,7 @@ class ContactSignatureVerifierTest {
// Assert // Assert
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
assertThat(result.contact.addresses).containsExactly(signed) assertThat(result.addresses).containsExactly(signed)
assertThat(result.invalidEntries).containsExactly(malformed)
} }
@Test @Test
@ -199,12 +191,7 @@ class ContactSignatureVerifierTest {
val result = verifier.verifyContacts(listOf(droppedContact, keptContact)) val result = verifier.verifyContacts(listOf(droppedContact, keptContact))
// Assert // Assert
assertThat(result).containsExactly( assertThat(result).containsExactly(keptContact.copy(addresses = listOf(validEntry)))
VerifiedContact(
contact = keptContact.copy(addresses = listOf(validEntry)),
invalidEntries = emptyList(),
),
)
} }
@Test @Test

View file

@ -42,7 +42,6 @@ internal class ContactsBlockModel @Inject constructor(
combine( combine(
params.queryFlow.flatMapLatest { query -> params.queryFlow.flatMapLatest { query ->
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null) getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
.map { verified -> verified.map { it.contact } }
}, },
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { contacts, wallets -> contacts to wallets.values.toList() } ) { contacts, wallets -> contacts to wallets.values.toList() }

View file

@ -8,7 +8,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
@ -63,7 +63,7 @@ internal class AddressBookListModel @Inject constructor(
private val searchActive = MutableStateFlow(value = false) private val searchActive = MutableStateFlow(value = false)
private val selectedWalletId = MutableStateFlow<String?>(value = null) private val selectedWalletId = MutableStateFlow<String?>(value = null)
private val allContacts: SharedFlow<List<VerifiedContact>> = private val allContacts: SharedFlow<List<Contact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1) .shareIn(modelScope, SharingStarted.Lazily, replay = 1)
@ -179,8 +179,8 @@ internal class AddressBookListModel @Inject constructor(
} }
private data class ListInputs( private data class ListInputs(
val allContacts: List<VerifiedContact>, val allContacts: List<Contact>,
val matchedContacts: List<VerifiedContact>, val matchedContacts: List<Contact>,
val query: String, val query: String,
val selectedWalletId: String?, val selectedWalletId: String?,
val wallets: Map<UserWalletId, UserWallet>, val wallets: Map<UserWalletId, UserWallet>,

View file

@ -4,7 +4,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.MatchedContact
@ -23,8 +23,8 @@ import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList") @Suppress("LongParameterList")
internal class UpdateAddressBookListContentTransformer( internal class UpdateAddressBookListContentTransformer(
wallets: Map<UserWalletId, UserWallet>, wallets: Map<UserWalletId, UserWallet>,
private val allContacts: List<VerifiedContact>, private val allContacts: List<Contact>,
private val matchedContacts: List<VerifiedContact>, private val matchedContacts: List<Contact>,
private val mode: AddressBookRoute.ListMode, private val mode: AddressBookRoute.ListMode,
private val selectedWalletId: String?, private val selectedWalletId: String?,
private val query: String, private val query: String,
@ -73,14 +73,14 @@ internal class UpdateAddressBookListContentTransformer(
DefaultContactConverter(onContactClick).convertList(matchedContacts) DefaultContactConverter(onContactClick).convertList(matchedContacts)
is AddressBookRoute.ListMode.Selector -> is AddressBookRoute.ListMode.Selector ->
SelectorContactConverter(onPickContact) SelectorContactConverter(onPickContact)
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId)) .convertList(ContactMatcher.match(matchedContacts, mode.networkId))
} }
/** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */ /** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */
private fun totalWalletIds(): Set<String> = when (val mode = mode) { private fun totalWalletIds(): Set<String> = when (val mode = mode) {
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue } AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.walletId.stringValue }
is AddressBookRoute.ListMode.Selector -> is AddressBookRoute.ListMode.Selector ->
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId } ContactMatcher.match(allContacts, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
} }
private fun contentMode(): ContentMode = when (mode) { private fun contentMode(): ContentMode = when (mode) {

View file

@ -1,30 +1,29 @@
package com.tangem.features.addressbook.list.state.transformers.converter package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.VerifiedContact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
internal class DefaultContactConverter( internal class DefaultContactConverter(
private val onContactClick: (String) -> Unit, private val onContactClick: (String) -> Unit,
) : Converter<VerifiedContact, ContactUM> { ) : Converter<Contact, ContactUM> {
override fun convert(value: VerifiedContact): ContactUM { override fun convert(value: Contact): ContactUM {
val contact = value.contact val name = value.name.value
val name = contact.name.value
return ContactUM( return ContactUM(
id = contact.id.value, id = value.id.value,
walletId = contact.walletId.stringValue, walletId = value.walletId.stringValue,
name = name, name = name,
icon = AccountIconUM.CryptoPortfolio( icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon } value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == value.icon }
?: CryptoPortfolioIcon.Icon.Letter, ?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor } color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == value.iconColor }
?: CryptoPortfolioIcon.Color.Azure, ?: CryptoPortfolioIcon.Color.Azure,
), ),
networkAddressCount = contact.addresses.size, networkAddressCount = value.addresses.size,
onClick = { onContactClick(contact.id.value) }, onClick = { onContactClick(value.id.value) },
) )
} }
} }

View file

@ -7,7 +7,6 @@ import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.network.Network 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.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.AddressBookContactsBlockComponent
@ -48,7 +47,7 @@ internal class ContactsBlockModelTest {
fun resetMocks() { fun resetMocks() {
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender) clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender)
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
flowOf(linkedMapOf<UserWalletId, UserWallet>()) flowOf(linkedMapOf())
} }
@AfterEach @AfterEach
@ -61,7 +60,7 @@ internal class ContactsBlockModelTest {
fun `GIVEN matching contacts WHEN block populated THEN SendFlowWidgetShown sent once`() = runTest { fun `GIVEN matching contacts WHEN block populated THEN SendFlowWidgetShown sent once`() = runTest {
// Arrange // Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns
flowOf(listOf(verified(id = "1"), verified(id = "2"))) flowOf(listOf(contact(id = "1"), contact(id = "2")))
// Act // Act
createModel(testScope = this) createModel(testScope = this)
@ -91,7 +90,7 @@ internal class ContactsBlockModelTest {
// Arrange // Arrange
var clicked: MatchedContact? = null var clicked: MatchedContact? = null
every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns every { getVerifiedContactsInteractor.getVerifiedContacts(query = any(), userWalletId = null) } returns
flowOf(listOf(verified(id = "42"))) flowOf(listOf(contact(id = "42")))
val model = createModel(testScope = this, onContactClick = { clicked = it }) val model = createModel(testScope = this, onContactClick = { clicked = it })
advanceUntilIdle() advanceUntilIdle()
@ -103,9 +102,6 @@ internal class ContactsBlockModelTest {
assertThat(clicked?.contactId).isEqualTo("42") assertThat(clicked?.contactId).isEqualTo("42")
} }
private fun verified(id: String): VerifiedContact =
VerifiedContact(contact = contact(id = id), invalidEntries = emptyList())
private fun contact(id: String): Contact = Contact( private fun contact(id: String): Contact = Contact(
id = ContactId(id), id = ContactId(id),
walletId = UserWalletId("a"), walletId = UserWalletId("a"),

View file

@ -205,26 +205,23 @@ internal class AddressBookListModelTest {
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) } verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
} }
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact( private fun verifiedContact(id: String, name: String): Contact = Contact(
contact = Contact( id = ContactId(id),
id = ContactId(id), walletId = UserWalletId("a"),
walletId = UserWalletId("a"), name = ContactName(name).getOrNull()!!,
name = ContactName(name).getOrNull()!!, icon = "",
icon = "", iconColor = CryptoPortfolioIcon.Color.Azure.name,
iconColor = CryptoPortfolioIcon.Color.Azure.name, createdAt = TIMESTAMP,
createdAt = TIMESTAMP, updatedAt = TIMESTAMP,
updatedAt = TIMESTAMP, addresses = listOf(
addresses = listOf( AddressEntry(
AddressEntry( id = AddressEntryId("e-$id"),
id = AddressEntryId("e-$id"), address = "0xABC",
address = "0xABC", networkId = Network.RawID("ethereum"),
networkId = Network.RawID("ethereum"), memo = null,
memo = null, signature = "sig",
signature = "sig",
),
), ),
), ),
invalidEntries = emptyList(),
) )
private fun createModel( private fun createModel(

View file

@ -133,8 +133,8 @@ internal class UpdateAddressBookListContentTransformerTest {
} }
private fun transform( private fun transform(
allContacts: List<VerifiedContact>, allContacts: List<Contact>,
matchedContacts: List<VerifiedContact>, matchedContacts: List<Contact>,
selectedWalletId: String? = null, selectedWalletId: String? = null,
query: String = "", query: String = "",
): AddressBookListUM = UpdateAddressBookListContentTransformer( ): AddressBookListUM = UpdateAddressBookListContentTransformer(
@ -156,25 +156,22 @@ internal class UpdateAddressBookListContentTransformerTest {
private fun wallet(id: String, name: String): UserWallet = private fun wallet(id: String, name: String): UserWallet =
MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name) MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name)
private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact( private fun verified(walletId: String, name: String): Contact = Contact(
contact = Contact( id = ContactId(name + walletId),
id = ContactId(name + walletId), walletId = UserWalletId(stringValue = walletId),
walletId = UserWalletId(stringValue = walletId), name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, icon = "",
icon = "", iconColor = "Azure",
iconColor = "Azure", createdAt = "2026-06-10T14:30:00.000Z",
createdAt = "2026-06-10T14:30:00.000Z", updatedAt = "2026-06-10T14:30:00.000Z",
updatedAt = "2026-06-10T14:30:00.000Z", addresses = listOf(
addresses = listOf( AddressEntry(
AddressEntry( id = AddressEntryId(name),
id = AddressEntryId(name), address = "addr-$name",
address = "addr-$name", networkId = Network.RawID("ethereum"),
networkId = Network.RawID("ethereum"), memo = null,
memo = null, signature = "sig",
signature = "sig",
),
), ),
), ),
invalidEntries = emptyList(),
) )
} }

View file

@ -102,7 +102,6 @@ internal class SendDestinationModel @Inject constructor(
private val contacts: StateFlow<List<Contact>> = private val contacts: StateFlow<List<Contact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.map { verified -> verified.map { it.contact } }
.flowOn(dispatchers.default) .flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList()) .stateIn(modelScope, SharingStarted.Eagerly, emptyList())

View file

@ -390,7 +390,7 @@ internal class SendDestinationModelTest {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right() } returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns
flowOf(listOf(verified(buildContact(name = model.savedName, address = model.savedAddress)))) flowOf(listOf(buildContact(name = model.savedName, address = model.savedAddress)))
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
@ -476,7 +476,7 @@ internal class SendDestinationModelTest {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right() } returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns
flowOf(model.savedAddresses.map { verified(buildContact(address = it)) }) flowOf(model.savedAddresses.map { buildContact(address = it) })
val sut = buildBlockModel(isAddContactAvailable = model.isAddContactAvailable) val sut = buildBlockModel(isAddContactAvailable = model.isAddContactAvailable)
advanceUntilIdle() advanceUntilIdle()
@ -608,9 +608,6 @@ internal class SendDestinationModelTest {
) )
} }
private fun verified(contact: Contact): VerifiedContact =
VerifiedContact(contact = contact, invalidEntries = emptyList())
private fun buildContact(name: String = "Alice", address: String = "0xAddr"): Contact = Contact( private fun buildContact(name: String = "Alice", address: String = "0xAddr"): Contact = Contact(
id = ContactId("c1"), id = ContactId("c1"),
walletId = testUserWalletId, walletId = testUserWalletId,