Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 15:37:41 +01:00
commit 18da968728
7 changed files with 175 additions and 32 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -13,11 +14,12 @@ import com.tangem.domain.models.wallet.UserWalletId
* contact currently being edited. Address comparison is exact (matching the in-editor dedup in
* `AddValidatedAddressTransformer`), so case-sensitive chains are not falsely flagged.
*
* Reads the local snapshot ([AddressBookRepository.getContactsSync]) rather than the syncing flow, so validating on
* every keystroke/selection change never triggers a backend sync.
* Only verified addresses count as duplicates: contacts are run through [ContactSignatureVerifier] first, so
* an unverified/invalid entry never blocks the pair and the user is free to overwrite it.
*/
class CheckAddressDuplicateUseCase(
private val repository: AddressBookRepository,
private val contactSignatureVerifier: ContactSignatureVerifier,
) {
suspend operator fun invoke(
@ -27,7 +29,8 @@ class CheckAddressDuplicateUseCase(
excludeContactId: ContactId? = null,
): String? {
val contacts = repository.getContactsSync(userWalletId)
return contacts
return contactSignatureVerifier.verifyContacts(contacts)
.map { it.contact }
.firstOrNull { contact ->
contact.id != excludeContactId && contact.addresses.any { entry ->
entry.networkId.value == networkId && entry.address == address

View file

@ -3,13 +3,26 @@ package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Emits the contact with the given [id], carrying only its verified addresses. Entries whose signatures
* don't verify against the wallet are stripped, and a contact left with no verified addresses is treated
* as absent (`null`) the UI must never surface unverified addresses.
*/
class GetContactByIdUseCase(
private val repository: AddressBookRepository,
private val contactSignatureVerifier: ContactSignatureVerifier,
) {
operator fun invoke(id: ContactId): Flow<Contact?> =
repository.getAllContacts().map { contacts -> contacts.find { it.id == id } }
operator fun invoke(id: ContactId): Flow<Contact?> {
return repository.getAllContacts().map { contacts ->
val contact = contacts.find { it.id == id } ?: return@map null
contactSignatureVerifier.verifyContacts(listOf(contact))
.firstOrNull()
?.contact
}
}
}

View file

@ -19,14 +19,18 @@ class ContactSignatureVerifier(
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,
)
}
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,
)
}
.filter { verifiedContact ->
verifiedContact.contact.addresses.isNotEmpty()
}
}
suspend fun isNameVerified(contact: Contact): Boolean {

View file

@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.*
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
@ -17,13 +18,18 @@ import org.junit.jupiter.api.TestInstance
class CheckAddressDuplicateUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = CheckAddressDuplicateUseCase(repository)
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
private val useCase = CheckAddressDuplicateUseCase(repository, contactSignatureVerifier)
private val walletId = UserWalletId("0001")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
clearMocks(repository, contactSignatureVerifier)
// Default: every stored address verifies, so the use case sees the contacts unchanged.
coEvery { contactSignatureVerifier.verifyContacts(any()) } answers {
firstArg<List<Contact>>().map { VerifiedContact(contact = it, invalidEntries = emptyList()) }
}
}
@Test
@ -75,6 +81,21 @@ class CheckAddressDuplicateUseCaseTest {
assertThat(result).isNull()
}
@Test
fun `GIVEN the pair belongs only to an unverified entry WHEN invoke THEN returns null`() = runTest {
// Arrange
val contact = contact("Binance", "0xAAA", ETHEREUM)
coEvery { repository.getContactsSync(walletId) } returns listOf(contact)
// Verification strips the unverified address, so the pair is no longer held by any contact.
coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns emptyList()
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA")
// Assert
assertThat(result).isNull()
}
private fun contact(name: String, address: String, networkId: String, id: String = "id-$name"): Contact = Contact(
id = ContactId(id),
walletId = walletId,

View file

@ -1,12 +1,19 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.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.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
@ -20,28 +27,48 @@ import org.junit.jupiter.api.TestInstance
class GetContactByIdUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactByIdUseCase(repository)
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
private val useCase = GetContactByIdUseCase(repository, contactSignatureVerifier)
@BeforeEach
fun resetMocks() {
clearMocks(repository)
clearMocks(repository, contactSignatureVerifier)
}
@Test
fun `GIVEN matching id WHEN invoke THEN emits that contact`() = runTest {
fun `GIVEN matching id WHEN invoke THEN emits verified contact with only valid addresses`() = runTest {
// Arrange
val target = contact("id-2", "Bob")
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), target))
val valid = entry("addr-valid")
val invalid = entry("addr-invalid")
val stored = contact("id-2", "Bob", valid, invalid)
val verified = stored.copy(addresses = listOf(valid))
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), stored))
coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns
listOf(VerifiedContact(contact = verified, invalidEntries = listOf(invalid)))
// Act
val result = useCase(ContactId("id-2")).first()
// Assert
assertThat(result).isEqualTo(target)
assertThat(result).isEqualTo(verified)
}
@Test
fun `GIVEN no matching id WHEN invoke THEN emits null`() = runTest {
fun `GIVEN contact has no verified addresses WHEN invoke THEN emits null`() = runTest {
// Arrange
val stored = contact("id-2", "Bob", entry("addr-invalid"))
every { repository.getAllContacts() } returns flowOf(listOf(stored))
coEvery { contactSignatureVerifier.verifyContacts(listOf(stored)) } returns emptyList()
// Act
val result = useCase(ContactId("id-2")).first()
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN no matching id WHEN invoke THEN emits null without verifying`() = runTest {
// Arrange
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice")))
@ -50,9 +77,10 @@ class GetContactByIdUseCaseTest {
// Assert
assertThat(result).isNull()
coVerify(exactly = 0) { contactSignatureVerifier.verifyContacts(any()) }
}
private fun contact(id: String, name: String): Contact = Contact(
private fun contact(id: String, name: String, vararg addresses: AddressEntry): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("0001"),
name = requireNotNull(ContactName(name).getOrNull()),
@ -60,6 +88,14 @@ class GetContactByIdUseCaseTest {
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = emptyList(),
addresses = addresses.toList(),
)
private fun entry(id: String): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = "0x$id",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "AABB",
)
}

View file

@ -140,19 +140,73 @@ class ContactSignatureVerifierTest {
}
@Test
fun `GIVEN contact with no entries WHEN verifyContacts THEN keeps contact without verifying`() = runTest {
fun `GIVEN contact with no entries WHEN verifyContacts THEN contact is dropped without verifying`() = runTest {
// Arrange
val contact = contact()
// Act
val result = verifier.verifyContacts(listOf(contact)).single()
val result = verifier.verifyContacts(listOf(contact))
// Assert
assertThat(result.contact.addresses).isEmpty()
assertThat(result.invalidEntries).isEmpty()
assertThat(result).isEmpty()
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
}
@Test
fun `GIVEN all entries invalid WHEN verifyContacts THEN contact is dropped`() = 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, false).right()
// Act
val result = verifier.verifyContacts(listOf(contact))
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN entry with malformed signature only WHEN verifyContacts THEN contact is dropped`() = runTest {
// Arrange
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
val contact = contact(malformed)
every { verifyMessages(any(), any(), any()) } returns emptyList<Boolean>().right()
// Act
val result = verifier.verifyContacts(listOf(contact))
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN one contact fully invalid AND another valid WHEN verifyContacts THEN only the valid one is kept`() =
runTest {
// Arrange
val invalidEntry = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
val validEntry = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
val droppedContact = contact(invalidEntry).copy(id = ContactId("contact-dropped"))
val keptContact = contact(validEntry).copy(id = ContactId("contact-kept"))
every { verifyMessages(any(), any(), any()) } returnsMany listOf(
listOf(false).right(),
listOf(true).right(),
)
// Act
val result = verifier.verifyContacts(listOf(droppedContact, keptContact))
// Assert
assertThat(result).containsExactly(
VerifiedContact(
contact = keptContact.copy(addresses = listOf(validEntry)),
invalidEntries = emptyList(),
),
)
}
@Test
fun `GIVEN wallet cannot be resolved WHEN verifyContacts THEN contact is dropped`() = runTest {
// Arrange