Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-23 16:51:05 +01:00
parent 12d2634cea
commit 3fd67fb474
10 changed files with 429 additions and 627 deletions

View file

@ -2,14 +2,12 @@ package com.tangem.tap.di.domain
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.CreateContactUseCase
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase
import com.tangem.domain.addressbook.usecase.UpdateContactUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
@ -39,12 +37,6 @@ object AddressBookDomainModule {
)
}
@Provides
@Singleton
fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase {
return SignAddressEntriesUseCase(signUseCase = signUseCase)
}
@Provides
@Singleton
fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase {
@ -73,30 +65,16 @@ object AddressBookDomainModule {
@Provides
@Singleton
fun provideCreateContactUseCase(
fun provideSaveContactInteractor(
repository: AddressBookRepository,
validateContactNameUseCase: ValidateContactNameUseCase,
signAddressEntriesUseCase: SignAddressEntriesUseCase,
signUseCase: SignUseCase,
timestampProvider: IsoTimestampProvider,
): CreateContactUseCase {
return CreateContactUseCase(
): SaveContactInteractor {
return SaveContactInteractor(
repository = repository,
validateContactName = validateContactNameUseCase,
signAddressEntries = signAddressEntriesUseCase,
timestampProvider = timestampProvider,
)
}
@Provides
@Singleton
fun provideUpdateContactUseCase(
repository: AddressBookRepository,
signAddressEntriesUseCase: SignAddressEntriesUseCase,
timestampProvider: IsoTimestampProvider,
): UpdateContactUseCase {
return UpdateContactUseCase(
repository = repository,
signAddressEntries = signAddressEntriesUseCase,
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)
}

View file

@ -0,0 +1,104 @@
package com.tangem.domain.addressbook.interactor
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.model.AddressEntry
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.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
import com.tangem.utils.extensions.toHexString
import java.security.MessageDigest
import java.util.UUID
class SaveContactInteractor(
private val repository: AddressBookRepository,
private val validateContactName: ValidateContactNameUseCase,
private val signUseCase: SignUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
suspend fun createContact(
userWallet: UserWallet,
name: String,
iconColor: String,
addressEntries: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName(userWalletId, name)
.mapLeft(SaveContactError::Name)
.bind()
val now = timestampProvider.now()
val contact = Contact(
id = ContactId(UUID.randomUUID().toString()),
walletId = userWalletId,
name = validName,
icon = "",
iconColor = iconColor,
createdAt = now,
updatedAt = now,
addressEntries = addressEntries,
)
val signed = signAddressEntries(userWallet, contact)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
suspend fun updateContact(
userWallet: UserWallet,
contact: Contact,
name: String,
iconColor: String,
addressEntries: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val validName = ContactName(name)
.mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) }
.bind()
val updated = contact.copy(
name = validName,
iconColor = iconColor,
addressEntries = addressEntries,
updatedAt = timestampProvider.now(),
)
val signed = signAddressEntries(userWallet, updated)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
private suspend fun signAddressEntries(
userWallet: UserWallet,
contact: Contact,
): Either<SignHashesError, Contact> = either {
val entries = contact.addressEntries
if (entries.isEmpty()) return@either contact
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
val hashes = entries.map { entry -> hashEntry(contact, entry) }
val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
val signedEntries = entries.mapIndexed { index, entry ->
entry.copy(signature = signatures[index].toHexString())
}
contact.copy(addressEntries = signedEntries)
}
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
val payload = buildAddressEntryPayload(contact, entry)
return MessageDigest.getInstance("SHA-256").digest(payload)
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact
* Builds the canonical bytes that are signed for a single [AddressEntry]:
* `address + networkId + memo + contactId + name`.
*
* Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and `GetVerifiedContactsInteractor`
* Shared by `SaveContactInteractor` (which hashes and signs it) and `GetVerifiedContactsInteractor`
* (which verifies the signature against it), so the signed and verified payloads can never diverge.
*/
internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray {

View file

@ -1,57 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.model.AddressEntry
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.time.IsoTimestampProvider
import com.tangem.domain.models.network.Network
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. 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,
) {
suspend operator fun invoke(
userWallet: UserWallet,
name: String,
iconColor: String,
network: Network,
addressEntries: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName(userWalletId, name)
.mapLeft(SaveContactError::Name)
.bind()
val now = timestampProvider.now()
val contact = Contact(
id = ContactId(UUID.randomUUID().toString()),
walletId = userWalletId,
name = validName,
icon = "",
iconColor = iconColor,
createdAt = now,
updatedAt = now,
addressEntries = addressEntries,
)
val signed = signAddressEntries(userWallet, contact)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
}

View file

@ -1,41 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
import com.tangem.utils.extensions.toHexString
import java.security.MessageDigest
/**
* Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single
* signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo +
* contactId + name)` and the produced signature is stored back into [AddressEntry.signature].
*/
class SignAddressEntriesUseCase(
private val signUseCase: SignUseCase,
) {
suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> = either {
val entries = contact.addressEntries
if (entries.isEmpty()) return@either contact
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
val hashes = entries.map { entry -> hashEntry(contact, entry) }
val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
val signedEntries = entries.mapIndexed { index, entry ->
entry.copy(signature = signatures[index].toHexString())
}
contact.copy(addressEntries = signedEntries)
}
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
val payload = buildAddressEntryPayload(contact, entry)
return MessageDigest.getInstance("SHA-256").digest(payload)
}
}

View file

@ -1,43 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.model.AddressEntry
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
class UpdateContactUseCase(
private val repository: AddressBookRepository,
private val signAddressEntries: SignAddressEntriesUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
suspend operator fun invoke(
userWallet: UserWallet,
contact: Contact,
name: String,
iconColor: String,
addressEntries: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val validName = ContactName(name)
.mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) }
.bind()
val updated = contact.copy(
name = validName,
iconColor = iconColor,
addressEntries = addressEntries,
updatedAt = timestampProvider.now(),
)
val signed = signAddressEntries(userWallet, updated)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
signed
}
}

View file

@ -0,0 +1,318 @@
package com.tangem.domain.addressbook.interactor
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
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.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
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 com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SaveContactInteractorTest {
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
private val signUseCase: SignUseCase = mockk()
private val timestampProvider: IsoTimestampProvider = mockk {
every { now() } returns NEW_TIMESTAMP
}
private val interactor = SaveContactInteractor(
repository = repository,
validateContactName = ValidateContactNameUseCase(repository),
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)
// MockUserWalletFactory builds each wallet key with publicKey = curve.name bytes → secp256k1 key is "Secp256k1"
private val userWallet: UserWallet = MockUserWalletFactory.create()
private val secp256k1Key = "Secp256k1".toByteArray()
private val networkRawId = Network.RawID("ethereum")
@BeforeEach
fun resetMocks() {
clearMocks(repository, signUseCase, answers = false)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CreateContact {
private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo"))
@Test
fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest {
// Arrange
stubNoExistingContacts()
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
val contact = result.getOrNull()
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.walletId).isEqualTo(userWallet.walletId)
assertThat(contact.name.value).isEqualTo("Alice")
assertThat(contact.id.value).isNotEmpty()
assertThat(contact.createdAt).isEqualTo(NEW_TIMESTAMP)
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
assertThat(contact.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString())
}
@Test
fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() =
runTest {
// Arrange
stubNoExistingContacts()
val twoEntries = listOf(
entry(id = "addr-1", address = "0xabc", memo = "memo"),
entry(id = "addr-2", address = "0xdef", memo = null),
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>()
val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
// Act
interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries)
// Assert
assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key)
val persisted = saved.captured
assertThat(hashesSlot.captured.map { it.toHexString() })
.containsExactly(
expectedHash(persisted, twoEntries[0]).toHexString(),
expectedHash(persisted, twoEntries[1]).toHexString(),
)
.inOrder()
assertThat(persisted.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString(), signatures[1].toHexString())
.inOrder()
}
@Test
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
// Arrange
stubNoExistingContacts()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList())
// Assert
assertThat(result.getOrNull()).isEqualTo(saved.captured)
assertThat(saved.captured.addressEntries).isEmpty()
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
fun `GIVEN wallet without a secp256k1 key WHEN createContact THEN Signing NoSigningKey without persisting`() =
runTest {
// Arrange — a locked hot wallet exposes no key; validation must still pass first
val lockedWallet = mockk<UserWallet.Hot> {
every { walletId } returns userWallet.walletId
every { wallets } returns null
}
stubNoExistingContacts()
// Act
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled")))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest {
// Arrange
every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice")))
// Act
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
// Act
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
private fun stubNoExistingContacts() {
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class UpdateContact {
private val updatedEntries = listOf(entry(id = "addr-new", address = "0xnew", memo = "memo"))
@Test
fun `GIVEN existing contact WHEN updateContact THEN preserves id AND restamps AND persists without uniqueness check`() =
runTest {
// Arrange
val existing = contact(name = "Alice")
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = existing,
name = "Bob",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
// Assert
val contact = result.getOrNull()
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.id).isEqualTo(existing.id)
assertThat(contact.name.value).isEqualTo("Bob")
assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP)
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
assertThat(contact.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString())
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
}
@Test
fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.NoSigningKey.left()
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "Bob",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN blank name WHEN updateContact THEN Name Format without persisting`() = runTest {
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
}
private fun contact(name: String): Contact = Contact(
id = ContactId("id-$name"),
walletId = userWallet.walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = ORIGINAL_TIMESTAMP,
updatedAt = ORIGINAL_TIMESTAMP,
addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)),
)
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = networkRawId,
memo = memo,
signature = "sig",
networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
private companion object {
const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z"
const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z"
}
}

View file

@ -1,164 +0,0 @@
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
import com.tangem.domain.addressbook.model.*
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.*
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 CreateContactUseCaseTest {
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
private val expectedTimestamp = "2026-06-10T14:30:00.000Z"
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 }
private val addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = networkRawId,
memo = "memo",
signature = "sig",
networkName = "Ethereum",
),
)
private val signedEntries = listOf(addressEntries.first().copy(signature = "signed"))
@BeforeEach
fun resetMocks() {
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 signed contact`() = runTest {
every { repository.getContacts(walletId) } returns flowOf(emptyList())
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
val result = useCase(
userWallet = userWallet,
name = "Alice",
iconColor = "TestColor",
network = network,
addressEntries = addressEntries,
)
val contact = result.getOrNull()
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.walletId).isEqualTo(walletId)
assertThat(contact.name.value).isEqualTo("Alice")
assertThat(contact.id.value).isNotEmpty()
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",
iconColor = "TestColor",
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", iconColor = "TestColor")
)
)
val result = useCase(
userWallet = userWallet,
name = "alice",
iconColor = "TestColor",
network = network,
addressEntries = addressEntries,
)
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `invalid name fails without persisting`() = runTest {
every { repository.getContacts(walletId) } returns flowOf(emptyList())
val result = useCase(
userWallet = userWallet,
name = "",
iconColor = "TestColor",
network = network,
addressEntries = addressEntries,
)
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
private fun contact(name: String, iconColor: String): Contact = Contact(
id = ContactId("id-$name"),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = iconColor,
createdAt = expectedTimestamp,
updatedAt = expectedTimestamp,
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = "0xabc",
networkId = networkRawId,
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)
}

View file

@ -1,148 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
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.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 com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignAddressEntriesUseCaseTest {
private val signUseCase: SignUseCase = mockk()
private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase)
// The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1"
private val userWallet: UserWallet = MockUserWalletFactory.create()
private val secp256k1Key = "Secp256k1".toByteArray()
@BeforeEach
fun resetMocks() {
clearMocks(signUseCase)
}
@Test
fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest {
// Arrange
val contact = contact(
entry(id = "addr-1", address = "0xabc", memo = "memo"),
entry(id = "addr-2", address = "0xdef", memo = null),
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>()
val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
// Act
val result = useCase(userWallet, contact)
// Assert
// Signatures are applied in entry order, hex-encoded; all other fields are preserved
val expected = contact.copy(
addressEntries = listOf(
contact.addressEntries[0].copy(signature = signatures[0].toHexString()),
contact.addressEntries[1].copy(signature = signatures[1].toHexString()),
),
)
assertThat(result.getOrNull()).isEqualTo(expected)
// The wallet's primary secp256k1 key is the one signing
assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key)
// Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order
assertThat(hashesSlot.captured.map { it.toHexString() })
.containsExactly(
expectedHash(contact, contact.addressEntries[0]).toHexString(),
expectedHash(contact, contact.addressEntries[1]).toHexString(),
)
.inOrder()
}
@Test
fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest {
// Arrange
val contact = contact()
// Act
val result = useCase(userWallet, contact)
// Assert
assertThat(result.getOrNull()).isEqualTo(contact)
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest {
// Arrange — a locked hot wallet exposes no key
val lockedWallet = mockk<UserWallet.Hot> { every { wallets } returns null }
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
// Act
val result = useCase(lockedWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey)
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest {
// Arrange
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
coEvery { signUseCase(any<List<ByteArray>>(), any(), any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act
val result = useCase(userWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled"))
}
private fun contact(vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("011"),
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
}

View file

@ -1,145 +0,0 @@
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
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.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
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
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 UpdateContactUseCaseTest {
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
private val newTimestamp = "2026-06-10T14:30:00.000Z"
private val originalTimestamp = "2026-01-01T00:00:00.000Z"
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(
AddressEntry(
id = AddressEntryId("addr-new"),
address = "0xnew",
networkId = networkRawId,
memo = "memo",
signature = "sig2",
networkName = "Ethereum",
),
)
private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed"))
@BeforeEach
fun resetMocks() {
clearMocks(repository, signAddressEntries)
coEvery { signAddressEntries(eq(userWallet), any()) } answers {
secondArg<Contact>().copy(addressEntries = signedEntries).right()
}
}
@Test
fun `update preserves id and persists signed changes without checking uniqueness`() = runTest {
val existing = contact(name = "Alice", iconColor = "TestColor")
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
val result = useCase(
userWallet = userWallet,
contact = existing,
name = "Bob",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
val contact = result.getOrNull()
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.id).isEqualTo(existing.id)
assertThat(contact.name.value).isEqualTo("Bob")
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", iconColor = "TestColor"),
name = "Bob",
iconColor = "TestColor",
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", iconColor = "TestColor"),
name = "",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
private fun contact(name: String, iconColor: String): Contact = Contact(
id = ContactId("id-$name"),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = iconColor,
createdAt = originalTimestamp,
updatedAt = originalTimestamp,
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = "0xabc",
networkId = networkRawId,
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)
}