Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-22 16:34:43 +01:00
commit 43fbd775e2
10 changed files with 324 additions and 21 deletions

View file

@ -14,6 +14,7 @@ dependencies {
api(projects.domain.core)
api(projects.domain.models)
implementation(projects.domain.common)
implementation(projects.domain.transaction)
implementation(projects.domain.tokens)

View file

@ -1,10 +1,13 @@
package com.tangem.domain.addressbook.error
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.SignHashesError
sealed interface SaveContactError {
data class Name(val error: ContactNameValidationError) : SaveContactError
data class Address(val error: AddressValidation.Error) : SaveContactError
data class Signing(val error: SignHashesError) : SaveContactError
}

View file

@ -0,0 +1,12 @@
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

@ -9,27 +9,30 @@ import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.UserWallet
import java.util.UUID
/**
* Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique
* the current time.
* the current time. Every address entry is signed with [userWallet]'s key before the contact is
* persisted, so only signed contacts are ever stored.
*/
class CreateContactUseCase(
private val repository: AddressBookRepository,
private val validateContactName: ValidateContactNameUseCase,
private val signAddressEntries: SignAddressEntriesUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
@Suppress("LongParameterList")
suspend operator fun invoke(
userWalletId: UserWalletId,
userWallet: UserWallet,
name: String,
network: Network,
addressEntries: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName(userWalletId, name)
.mapLeft(SaveContactError::Name)
.bind()
@ -43,7 +46,10 @@ class CreateContactUseCase(
updatedAt = now,
addressEntries = addressEntries,
)
repository.saveContact(contact)
contact
val signed = signAddressEntries(userWallet, contact)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetVerifiedContactsUseCase(
private val getContacts: GetContactsUseCase,
private val verifyAddressEntries: VerifyAddressEntriesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
operator fun invoke(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 = verifyAddressEntries(userWallet, contact).getOrNull() ?: return@mapNotNull null
VerifiedContact(
contact = contact.copy(addressEntries = verification.valid),
invalidEntries = verification.invalid,
)
}
}
}
}

View file

@ -9,18 +9,16 @@ import com.tangem.domain.addressbook.model.Contact
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.models.wallet.UserWallet
/**
* format-checked uniqueness is not re-validated on update. Address entries must be prepared and
* validated before calling this use case. [Contact.updatedAt] is restamped with the current time.
*/
class UpdateContactUseCase(
private val repository: AddressBookRepository,
private val signAddressEntries: SignAddressEntriesUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
suspend operator fun invoke(
userWallet: UserWallet,
contact: Contact,
name: String,
addressEntries: List<AddressEntry>,
@ -34,7 +32,10 @@ class UpdateContactUseCase(
addressEntries = addressEntries,
updatedAt = timestampProvider.now(),
)
repository.saveContact(updated)
updated
val signed = signAddressEntries(userWallet, updated)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
@ -11,7 +13,9 @@ 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.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 io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -32,13 +36,16 @@ class CreateContactUseCaseTest {
private val timestampProvider: IsoTimestampProvider = mockk {
every { now() } returns expectedTimestamp
}
private val signAddressEntries: SignAddressEntriesUseCase = mockk()
private val useCase = CreateContactUseCase(
repository = repository,
validateContactName = ValidateContactNameUseCase(repository),
signAddressEntries = signAddressEntries,
timestampProvider = timestampProvider,
)
private val walletId = UserWalletId("011")
private val userWallet: UserWallet = mockk { every { walletId } returns this@CreateContactUseCaseTest.walletId }
private val networkRawId = Network.RawID("ethereum")
private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None)
private val network: Network = mockk { every { id } returns networkId }
@ -53,19 +60,25 @@ class CreateContactUseCaseTest {
),
)
private val signedEntries = listOf(addressEntries.first().copy(signature = "signed"))
@BeforeEach
fun resetMocks() {
clearMocks(repository)
clearMocks(repository, signAddressEntries)
// Sign returns the contact with signed entries; the persisted contact must be the signed one.
coEvery { signAddressEntries(eq(userWallet), any()) } answers {
secondArg<Contact>().copy(addressEntries = signedEntries).right()
}
}
@Test
fun `create generates ids and persists the contact`() = runTest {
fun `create generates ids and persists the signed contact`() = runTest {
every { repository.getContacts(walletId) } returns flowOf(emptyList())
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
val result = useCase(
userWalletId = walletId,
userWallet = userWallet,
name = "Alice",
network = network,
addressEntries = addressEntries,
@ -76,17 +89,33 @@ class CreateContactUseCaseTest {
assertThat(contact!!.walletId).isEqualTo(walletId)
assertThat(contact.name.value).isEqualTo("Alice")
assertThat(contact.id.value).isNotEmpty()
assertThat(contact.addressEntries).isEqualTo(addressEntries)
assertThat(contact.addressEntries).isEqualTo(signedEntries)
assertThat(contact.createdAt).isEqualTo(expectedTimestamp)
assertThat(contact.updatedAt).isEqualTo(expectedTimestamp)
}
@Test
fun `signing failure fails without persisting`() = runTest {
every { repository.getContacts(walletId) } returns flowOf(emptyList())
coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left()
val result = useCase(
userWallet = userWallet,
name = "Alice",
network = network,
addressEntries = addressEntries,
)
assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `duplicate name fails without persisting`() = runTest {
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
val result = useCase(
userWalletId = walletId,
userWallet = userWallet,
name = "alice",
network = network,
addressEntries = addressEntries,
@ -102,7 +131,7 @@ class CreateContactUseCaseTest {
every { repository.getContacts(walletId) } returns flowOf(emptyList())
val result = useCase(
userWalletId = walletId,
userWallet = userWallet,
name = "",
network = network,
addressEntries = addressEntries,

View file

@ -0,0 +1,116 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntriesVerification
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 io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
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 GetVerifiedContactsUseCaseTest {
private val getContacts: GetContactsUseCase = mockk()
private val verifyAddressEntries: VerifyAddressEntriesUseCase = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val useCase = GetVerifiedContactsUseCase(
getContacts = getContacts,
verifyAddressEntries = verifyAddressEntries,
userWalletsListRepository = userWalletsListRepository,
)
private val walletId = UserWalletId("011")
private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsUseCaseTest.walletId }
private val validEntry = entry(id = "valid", address = "0xvalid")
private val invalidEntry = entry(id = "invalid", address = "0xinvalid")
private val contact = contact(name = "Alice", entries = listOf(validEntry, invalidEntry))
@BeforeEach
fun resetMocks() {
clearMocks(getContacts, verifyAddressEntries, userWalletsListRepository)
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
}
@Test
fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest {
// Arrange
every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact))
every { verifyAddressEntries(userWallet, contact) } returns
AddressEntriesVerification(valid = listOf(validEntry), invalid = listOf(invalidEntry)).right()
// Act
val result = useCase(query = "").first()
// Assert
assertThat(result).containsExactly(
VerifiedContact(
contact = contact.copy(addressEntries = listOf(validEntry)),
invalidEntries = listOf(invalidEntry),
),
)
}
@Test
fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest {
// Arrange
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact))
// Act
val result = useCase(query = "").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest {
// Arrange
every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact))
every { verifyAddressEntries(userWallet, contact) } returns VerifyMessagesError.NoSigningKey.left()
// Act
val result = useCase(query = "").first()
// Assert
assertThat(result).isEmpty()
}
private fun entry(id: String, address: String): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig-$id",
)
private fun contact(name: String, entries: List<AddressEntry>): Contact = Contact(
id = ContactId("id-$name"),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = entries,
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
@ -11,7 +13,9 @@ 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.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 io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -32,12 +36,15 @@ class UpdateContactUseCaseTest {
private val timestampProvider: IsoTimestampProvider = mockk {
every { now() } returns newTimestamp
}
private val signAddressEntries: SignAddressEntriesUseCase = mockk()
private val useCase = UpdateContactUseCase(
repository = repository,
signAddressEntries = signAddressEntries,
timestampProvider = timestampProvider,
)
private val walletId = UserWalletId("011")
private val userWallet: UserWallet = mockk { every { walletId } returns this@UpdateContactUseCaseTest.walletId }
private val networkRawId = Network.RawID("ethereum")
private val updatedEntries = listOf(
@ -50,18 +57,24 @@ class UpdateContactUseCaseTest {
),
)
private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed"))
@BeforeEach
fun resetMocks() {
clearMocks(repository)
clearMocks(repository, signAddressEntries)
coEvery { signAddressEntries(eq(userWallet), any()) } answers {
secondArg<Contact>().copy(addressEntries = signedEntries).right()
}
}
@Test
fun `update preserves id and persists changes without checking uniqueness`() = runTest {
fun `update preserves id and persists signed changes without checking uniqueness`() = runTest {
val existing = contact(name = "Alice")
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
val result = useCase(
userWallet = userWallet,
contact = existing,
name = "Bob",
addressEntries = updatedEntries,
@ -71,15 +84,31 @@ class UpdateContactUseCaseTest {
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.id).isEqualTo(existing.id)
assertThat(contact.name.value).isEqualTo("Bob")
assertThat(contact.addressEntries).isEqualTo(updatedEntries)
assertThat(contact.addressEntries).isEqualTo(signedEntries)
assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved
assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
}
@Test
fun `signing failure fails without persisting`() = runTest {
coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left()
val result = useCase(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "Bob",
addressEntries = updatedEntries,
)
assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `invalid name fails without persisting`() = runTest {
val result = useCase(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "",
addressEntries = updatedEntries,