Updated on 2026-08-14
This commit is contained in:
parent
1cfaf5a283
commit
4ebe22ed22
7 changed files with 175 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue