Updated on 2026-08-14
This commit is contained in:
commit
43fbd775e2
10 changed files with 324 additions and 21 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue