Updated on 2026-08-14
This commit is contained in:
commit
b2da68216a
501 changed files with 16767 additions and 5720 deletions
|
|
@ -9,7 +9,7 @@ import com.tangem.domain.account.status.model.AccountCryptoCurrency
|
|||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,32 +1,42 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.domain.addressbook.model.AddressEntriesVerification
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.hexToBytesOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones
|
||||
* whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that
|
||||
* were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase].
|
||||
*
|
||||
* An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex);
|
||||
* such entries should be hidden from the user. Both partitions preserve the contact's original entry
|
||||
* order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces
|
||||
* as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all).
|
||||
*
|
||||
* Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]).
|
||||
*/
|
||||
class VerifyAddressEntriesUseCase(
|
||||
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
|
||||
class GetVerifiedContactsInteractor(
|
||||
private val getContacts: GetContactsUseCase,
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(
|
||||
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 = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addressEntries = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
|
|
@ -40,7 +50,7 @@ class VerifyAddressEntriesUseCase(
|
|||
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
|
||||
val signatures = wellFormed.map { (_, signature) -> signature }
|
||||
|
||||
return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
.map { flags ->
|
||||
val validIds = wellFormed
|
||||
.filterIndexed { index, _ -> flags[index] }
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ data class AddressEntry(
|
|||
val id: AddressEntryId,
|
||||
val address: String,
|
||||
val networkId: Network.RawID,
|
||||
val networkName: String,
|
||||
val memo: String?,
|
||||
val signature: String,
|
||||
)
|
||||
|
|
@ -15,6 +15,8 @@ data class Contact(
|
|||
val id: ContactId,
|
||||
val walletId: UserWalletId,
|
||||
val name: ContactName,
|
||||
val icon: String,
|
||||
val iconColor: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val addressEntries: List<AddressEntry>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -8,10 +8,11 @@ import kotlinx.coroutines.flow.Flow
|
|||
/** Persistence port for the address book. The implementation is provided by the data layer. */
|
||||
interface AddressBookRepository {
|
||||
|
||||
/** Contacts for a single wallet. Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>>
|
||||
|
||||
/** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getContacts(userWalletIds: Set<UserWalletId>): Flow<List<Contact>>
|
||||
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getAllContacts(): Flow<List<Contact>>
|
||||
|
||||
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [VerifyAddressEntriesUseCase]
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -1,49 +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.UserWalletId
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique
|
||||
|
||||
* the current time.
|
||||
*/
|
||||
class CreateContactUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
private val validateContactName: ValidateContactNameUseCase,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
name: String,
|
||||
network: Network,
|
||||
addressEntries: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
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,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
repository.saveContact(contact)
|
||||
contact
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,28 @@ import com.tangem.domain.addressbook.model.Contact
|
|||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GetContactsUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletIds: Set<UserWalletId>): Flow<List<Contact>> = repository.getContacts(userWalletIds)
|
||||
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<Contact>> {
|
||||
val source = if (userWalletId == null) {
|
||||
repository.getAllContacts()
|
||||
} else {
|
||||
repository.getContacts(userWalletId)
|
||||
}
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isEmpty()) return source
|
||||
return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } }
|
||||
}
|
||||
|
||||
private fun Contact.matches(query: String): Boolean {
|
||||
val isNameContaining = name.value.contains(other = query, ignoreCase = true)
|
||||
val isAddressContaining = addressEntries.any { addressEntry ->
|
||||
addressEntry.address.contains(other = query, ignoreCase = true)
|
||||
}
|
||||
return isNameContaining || isAddressContaining
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +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
|
||||
|
||||
/**
|
||||
|
||||
* 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 timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
contact: Contact,
|
||||
name: 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,
|
||||
addressEntries = addressEntries,
|
||||
updatedAt = timestampProvider.now(),
|
||||
)
|
||||
repository.saveContact(updated)
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,16 @@ internal class AddressBookCipherTest {
|
|||
fun `GIVEN multi-contact book WHEN encrypt then decrypt THEN original book is restored`() {
|
||||
// Arrange
|
||||
val book = addressBook(
|
||||
contact("Alice", entry("addr-1", "0xabc", memo = "memo")),
|
||||
contact("Bob", entry("addr-2", "0xdef", memo = null)),
|
||||
contact(
|
||||
name = "Alice",
|
||||
iconColor = "TestColor1",
|
||||
entries = arrayOf(entry("addr-1", "0xabc", memo = "memo")),
|
||||
),
|
||||
contact(
|
||||
name = "Bob",
|
||||
iconColor = "TestColor2",
|
||||
entries = arrayOf(entry("addr-2", "0xdef", memo = null)),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
|
|
@ -64,7 +72,13 @@ internal class AddressBookCipherTest {
|
|||
@Test
|
||||
fun `GIVEN a book WHEN encrypt THEN blob metadata and field sizes match the spec`() {
|
||||
// Arrange
|
||||
val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null)))
|
||||
val book = addressBook(
|
||||
contact(
|
||||
name = "Alice",
|
||||
iconColor = "TestColor",
|
||||
entries = arrayOf(entry("addr-1", "0xabc", memo = null)),
|
||||
)
|
||||
)
|
||||
|
||||
// Act
|
||||
val blob = cipher.encrypt(book, wallet, updatedAt).rightValue()
|
||||
|
|
@ -94,7 +108,13 @@ internal class AddressBookCipherTest {
|
|||
@Test
|
||||
fun `GIVEN same book encrypted twice WHEN compared THEN nonce differs but both decrypt to original`() {
|
||||
// Arrange
|
||||
val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null)))
|
||||
val book = addressBook(
|
||||
contact(
|
||||
name = "Alice",
|
||||
iconColor = "TestColor",
|
||||
entries = arrayOf(entry("addr-1", "0xabc", memo = null)),
|
||||
)
|
||||
)
|
||||
|
||||
// Act
|
||||
val first = cipher.encrypt(book, wallet, updatedAt).rightValue()
|
||||
|
|
@ -201,20 +221,42 @@ internal class AddressBookCipherTest {
|
|||
assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.NoWalletPublicKey)
|
||||
}
|
||||
|
||||
// region cross-platform vectors
|
||||
// Shared known-answer vector, identical to iOS CommonAddressBookEncryptionServiceTests. Asserting the
|
||||
// same bytes on both platforms guarantees a blob sealed on one opens on the other. Do not change these
|
||||
// constants without changing the iOS suite in lockstep.
|
||||
|
||||
@Test
|
||||
fun `GIVEN fixed public key WHEN deriveAesKey THEN matches the locked HMAC-SHA256 vector`() {
|
||||
// Arrange — independently computed: HMAC-SHA256(SHA-256([01,02,03,04]), "TokensSymmetricKey")
|
||||
val publicKey = byteArrayOf(0x01, 0x02, 0x03, 0x04)
|
||||
val expected = "da48094b89902e137ae73ae90acbd809af9ad4f648044c17e7ee6de73e96b0c2"
|
||||
fun `GIVEN shared cross-platform public key WHEN deriveAesKey THEN matches the iOS vector`() {
|
||||
// Arrange
|
||||
val publicKey = VECTOR_PUBLIC_KEY_HEX.hexToBytes()
|
||||
|
||||
// Act
|
||||
val aesKey = AddressBookKeyDerivation.deriveAesKey(publicKey)
|
||||
|
||||
// Assert
|
||||
assertThat(aesKey).hasLength(AES_256_KEY_BYTES)
|
||||
assertThat(aesKey.toHexString().lowercase()).isEqualTo(expected)
|
||||
assertThat(aesKey.toHexString().lowercase()).isEqualTo(VECTOR_KEY_HEX)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a blob sealed on the other platform WHEN decrypt with the derived key THEN restores the plaintext`() {
|
||||
// Arrange — open the iOS-produced AES-256-GCM box with the key derived from the shared seed
|
||||
val aesKey = AddressBookKeyDerivation.deriveAesKey(VECTOR_PUBLIC_KEY_HEX.hexToBytes())
|
||||
|
||||
// Act
|
||||
val plaintext = aesGcmOpen(
|
||||
key = aesKey,
|
||||
nonce = VECTOR_NONCE_HEX.hexToBytes(),
|
||||
ciphertext = VECTOR_CIPHERTEXT_HEX.hexToBytes(),
|
||||
authTag = VECTOR_TAG_HEX.hexToBytes(),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(plaintext.toString(Charsets.UTF_8)).isEqualTo(VECTOR_PLAINTEXT)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
private fun addressBook(vararg contacts: Contact): AddressBook =
|
||||
AddressBook(walletId = wallet.walletId, contacts = contacts.toList())
|
||||
|
|
@ -222,10 +264,12 @@ internal class AddressBookCipherTest {
|
|||
private fun addressBook(walletId: UserWalletId): AddressBook =
|
||||
AddressBook(walletId = walletId, contacts = emptyList())
|
||||
|
||||
private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact(
|
||||
private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact(
|
||||
id = ContactId("contact-$name"),
|
||||
walletId = wallet.walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-05-22T09:00:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
|
|
@ -237,10 +281,19 @@ internal class AddressBookCipherTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = "",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
|
||||
|
||||
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
/** Raw AES-256-GCM open, mirroring [AddressBookCipher]'s transformation and tag size. */
|
||||
private fun aesGcmOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, authTag: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
|
||||
}.doFinal(ciphertext + authTag)
|
||||
|
||||
private fun <T> Either<AddressBookCryptoError, T>.rightValue(): T =
|
||||
getOrNull() ?: error("Expected Either.Right but was $this")
|
||||
|
||||
|
|
@ -272,5 +325,13 @@ internal class AddressBookCipherTest {
|
|||
const val NONCE_HEX_LENGTH = NONCE_BYTES * 2
|
||||
const val TAG_HEX_LENGTH = TAG_BYTES * 2
|
||||
const val AES_256_KEY_BYTES = 32
|
||||
|
||||
// Shared cross-platform known-answer vector (see iOS CommonAddressBookEncryptionServiceTests).
|
||||
const val VECTOR_PUBLIC_KEY_HEX = "0374d0f81f42ddfe34114d533e95e6ae5fe6ea271c96f1fa505199fdc365ae9720"
|
||||
const val VECTOR_KEY_HEX = "59b85ce53fac0a8493d9d8d9c0d32adb5f586741dd8bbfd9348a3212e493730d"
|
||||
const val VECTOR_NONCE_HEX = "000102030405060708090a0b"
|
||||
const val VECTOR_CIPHERTEXT_HEX = "f4ee0f404e747b5b5cca730c44baf86ca3d8f6fbdf66ff2fe98d3b8f88cb23df7ff55b52205f32c8ab"
|
||||
const val VECTOR_TAG_HEX = "6c4b71b27958f43afc6633850369a17a"
|
||||
const val VECTOR_PLAINTEXT = "Tangem Address Book cross-platform vector"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
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.usecase.GetContactsUseCase
|
||||
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 com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
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 GetVerifiedContactsInteractorTest {
|
||||
|
||||
private val getContacts: GetContactsUseCase = mockk()
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
|
||||
private val interactor = GetVerifiedContactsInteractor(
|
||||
getContacts = getContacts,
|
||||
verifyMessages = verifyMessages,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsInteractorTest.walletId }
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(getContacts, verifyMessages, 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
|
||||
val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD")
|
||||
val contact = contact(valid, invalid)
|
||||
stubContacts(contact)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addressEntries = listOf(valid)),
|
||||
invalidEntries = listOf(invalid),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
stubContacts(contact)
|
||||
val messagesSlot = slot<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
|
||||
} returns listOf(true, true).right()
|
||||
|
||||
// Act
|
||||
interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addressEntries[0]),
|
||||
expectedPayload(contact, contact.addressEntries[1]),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest {
|
||||
// Arrange
|
||||
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
|
||||
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
|
||||
val contact = contact(valid1, invalid, valid2)
|
||||
stubContacts(contact)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addressEntries).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalidEntries).containsExactly(invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest {
|
||||
// Arrange
|
||||
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
|
||||
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
|
||||
val contact = contact(malformed, signed)
|
||||
stubContacts(contact)
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
|
||||
} returns listOf(true).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result.contact.addressEntries).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
stubContacts(contact)
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addressEntries).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
private fun stubContacts(vararg contacts: Contact) {
|
||||
every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList())
|
||||
}
|
||||
|
||||
private fun contact(vararg entries: AddressEntry): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = walletId,
|
||||
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?, signature: String): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
networkName = "Ethereum",
|
||||
memo = memo,
|
||||
signature = signature,
|
||||
)
|
||||
|
||||
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
|
||||
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
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.UserWalletId
|
||||
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.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 useCase = CreateContactUseCase(
|
||||
repository = repository,
|
||||
validateContactName = ValidateContactNameUseCase(repository),
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create generates ids and persists the 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,
|
||||
name = "Alice",
|
||||
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(addressEntries)
|
||||
assertThat(contact.createdAt).isEqualTo(expectedTimestamp)
|
||||
assertThat(contact.updatedAt).isEqualTo(expectedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate name fails without persisting`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(
|
||||
userWalletId = walletId,
|
||||
name = "alice",
|
||||
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(
|
||||
userWalletId = walletId,
|
||||
name = "",
|
||||
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): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
createdAt = expectedTimestamp,
|
||||
updatedAt = expectedTimestamp,
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
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 GetContactsUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk()
|
||||
private val useCase = GetContactsUseCase(repository)
|
||||
|
||||
private val alice = contact(name = "Alice", address = "0xaaa")
|
||||
private val bob = contact(name = "Bob", address = "0xbbb")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
every { repository.getAllContacts() } returns flowOf(listOf(alice, bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches a name WHEN invoke THEN returns only matching contacts`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "ali").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches an address WHEN invoke THEN returns only matching contacts`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "0xbbb").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(bob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank query WHEN invoke THEN returns all contacts unfiltered`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = " ").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice, bob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches nothing WHEN invoke THEN returns empty list`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "charlie").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest {
|
||||
// Arrange
|
||||
val walletId = UserWalletId("011")
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(alice))
|
||||
|
||||
// Act
|
||||
val result = useCase(query = "", userWalletId = walletId).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice)
|
||||
verify(exactly = 1) { repository.getContacts(walletId) }
|
||||
verify(exactly = 0) { repository.getAllContacts() }
|
||||
}
|
||||
|
||||
private fun contact(name: String, address: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = UserWalletId("011"),
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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.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()),
|
||||
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 = "",
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
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.UserWalletId
|
||||
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 useCase = UpdateContactUseCase(
|
||||
repository = repository,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val networkRawId = Network.RawID("ethereum")
|
||||
|
||||
private val updatedEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-new"),
|
||||
address = "0xnew",
|
||||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig2",
|
||||
),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update preserves id and persists changes without checking uniqueness`() = runTest {
|
||||
val existing = contact(name = "Alice")
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
val result = useCase(
|
||||
contact = existing,
|
||||
name = "Bob",
|
||||
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(updatedEntries)
|
||||
assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved
|
||||
assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid name fails without persisting`() = runTest {
|
||||
val result = useCase(
|
||||
contact = contact(name = "Alice"),
|
||||
name = "",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
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 = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
createdAt = originalTimestamp,
|
||||
updatedAt = originalTimestamp,
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ class ValidateContactNameUseCaseTest {
|
|||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
|
|
@ -73,6 +75,7 @@ class ValidateContactNameUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,170 +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.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.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class VerifyAddressEntriesUseCaseTest {
|
||||
|
||||
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
|
||||
|
||||
private val userWallet: UserWallet = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(verifyMessagesUseCase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
val messagesSlot = slot<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessagesUseCase(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
|
||||
} returns listOf(true, true).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact)
|
||||
|
||||
// Assert
|
||||
// Each entry is verified against address + networkId + memo + contactId + name
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addressEntries[0]),
|
||||
expectedPayload(contact, contact.addressEntries[1]),
|
||||
)
|
||||
.inOrder()
|
||||
// Hex signatures are decoded to bytes, in entry order
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() {
|
||||
// Arrange
|
||||
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
|
||||
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
|
||||
val contact = contact(valid1, invalid, valid2)
|
||||
every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(true, false, true).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact).getOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(result!!.valid).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalid).containsExactly(invalid)
|
||||
assertThat(result.areAllInvalid).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() {
|
||||
// Arrange
|
||||
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
|
||||
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
|
||||
val contact = contact(malformed, signed)
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessagesUseCase(eq(userWallet), any(), capture(signaturesSlot))
|
||||
} returns listOf(true).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact).getOrNull()
|
||||
|
||||
// Assert
|
||||
// Only the well-formed entry is passed to verification
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result!!.valid).containsExactly(signed)
|
||||
assertThat(result.invalid).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN every entry is invalid WHEN invoke THEN allInvalid is true`() {
|
||||
// Arrange
|
||||
val entry1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
|
||||
val entry2 = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
|
||||
val contact = contact(entry1, entry2)
|
||||
every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(false, false).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact).getOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(result!!.valid).isEmpty()
|
||||
assertThat(result.invalid).containsExactly(entry1, entry2).inOrder()
|
||||
assertThat(result.areAllInvalid).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN invoke THEN returns empty partition without verifying`() {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact).getOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(result!!.valid).isEmpty()
|
||||
assertThat(result.invalid).isEmpty()
|
||||
assertThat(result.areAllInvalid).isFalse()
|
||||
verify(exactly = 0) { verifyMessagesUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verifyMessagesUseCase returns error WHEN invoke THEN propagates the error`() {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessagesUseCase(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey)
|
||||
}
|
||||
|
||||
private fun contact(vararg entries: AddressEntry): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = UserWalletId("011"),
|
||||
name = requireNotNull(ContactName("Alice").getOrNull()),
|
||||
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?, signature: String): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = signature,
|
||||
)
|
||||
|
||||
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
|
||||
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
|
||||
}
|
||||
|
|
@ -7,5 +7,7 @@ plugins {
|
|||
dependencies {
|
||||
implementation(deps.moshi.adapters)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
package com.tangem.domain.express.models
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* A crypto asset leg of an express operation: which asset and how much of it moved.
|
||||
*
|
||||
* @property id The asset identifier (network id + contract address).
|
||||
* @property amount Human-readable amount (already scaled by [decimals]).
|
||||
* @property amount Human-readable amount (already scaled by [decimals]); `null` when the backend provided no amount.
|
||||
* @property decimals The asset's decimals.
|
||||
* @property cryptoCurrency The portfolio [CryptoCurrency] this asset was resolved to (matched by network id +
|
||||
* contract address across all accounts). `null` when no portfolio currency matched and no fallback could be built.
|
||||
*/
|
||||
data class ExpressTransactionAsset(
|
||||
val id: ExpressAsset.ID,
|
||||
val amount: BigDecimal,
|
||||
val amount: BigDecimal?,
|
||||
val decimals: Int,
|
||||
val cryptoCurrency: CryptoCurrency? = null,
|
||||
)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.domain.express.models
|
||||
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import com.tangem.domain.tokens.model.AmountType
|
|||
* @property payoutHash On-chain hash of the payout (received) leg, if known.
|
||||
* @property fromFiat The fiat paid.
|
||||
* @property toAsset The crypto asset received.
|
||||
* @property country The country the onramp was made from; `null` if not resolved.
|
||||
*/
|
||||
data class OnrampTransaction(
|
||||
val txId: String,
|
||||
|
|
@ -25,4 +27,5 @@ data class OnrampTransaction(
|
|||
/** The [Amount.type] is [AmountType.FiatType] . */
|
||||
val fromFiat: Amount,
|
||||
val toAsset: ExpressTransactionAsset,
|
||||
val country: OnrampCountry? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.domain.models.network
|
||||
|
||||
import com.tangem.domain.models.serialization.SerializedBigDecimal
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Domain mirror of the blockchain SDK `Amount`, kept [Serializable] so it can be carried inside the serializable
|
||||
* [TxInfo] graph (the SDK `Amount` is not serializable and pulls in blockchain-specific types).
|
||||
*
|
||||
* Holds a monetary value together with the metadata needed to display it. Compared to the SDK model it drops
|
||||
* `maxValue` (irrelevant outside of "send" flows) and keeps only the currency identity on [SdkAmountType].
|
||||
*
|
||||
* @property currencySymbol display symbol of the currency (e.g. `ETH`, `USDT`)
|
||||
* @property value amount value; `null` when the value is unknown
|
||||
* @property decimals number of decimals of the currency
|
||||
* @property type kind of currency the amount is denominated in
|
||||
*/
|
||||
@Serializable
|
||||
data class SdkAmount(
|
||||
val currencySymbol: String,
|
||||
val value: SerializedBigDecimal? = null,
|
||||
val decimals: Int,
|
||||
val type: SdkAmountType = SdkAmountType.Coin,
|
||||
)
|
||||
|
||||
/** Kind of currency an [SdkAmount] is denominated in. Mirrors the SDK `AmountType`. */
|
||||
@Serializable
|
||||
sealed interface SdkAmountType {
|
||||
|
||||
/** Native coin of the blockchain. */
|
||||
@Serializable
|
||||
data object Coin : SdkAmountType
|
||||
|
||||
/** Native coin used as a reserve currency for fee calculation (e.g. Algorand). */
|
||||
@Serializable
|
||||
data object Reserve : SdkAmountType
|
||||
|
||||
/** A resource that can be spent to pay the fee (e.g. Mana on Koinos). */
|
||||
@Serializable
|
||||
data class FeeResource(val name: String? = null) : SdkAmountType
|
||||
|
||||
/**
|
||||
* A token of the blockchain.
|
||||
*
|
||||
* @property contractAddress token contract address
|
||||
* @property id backend currency id, when known
|
||||
*/
|
||||
@Serializable
|
||||
data class Token(val contractAddress: String, val id: String? = null) : SdkAmountType
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import kotlinx.serialization.Serializable
|
|||
* @property status transaction status
|
||||
* @property type transaction type
|
||||
* @property amount transaction amount
|
||||
* @property fee transaction fee
|
||||
*/
|
||||
@Serializable
|
||||
data class TxInfo(
|
||||
|
|
@ -27,6 +28,7 @@ data class TxInfo(
|
|||
val status: TransactionStatus,
|
||||
val type: TransactionType,
|
||||
val amount: SerializedBigDecimal,
|
||||
val fee: SdkAmount? = null,
|
||||
) {
|
||||
|
||||
/** Destination type*/
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ package com.tangem.domain.pushnotificationpreferences.models
|
|||
|
||||
data class PushNotificationPreference(
|
||||
val isEnabled: Boolean,
|
||||
val isVisible: Boolean,
|
||||
)
|
||||
|
|
@ -295,7 +295,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
private suspend fun getEIP7702DataForGasless(
|
||||
gaslessDataProvider: EthereumGaslessDataProvider,
|
||||
): EIP7702AuthorizationData {
|
||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) {
|
||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) {
|
||||
is Result.Failure -> throw dataResult.error
|
||||
is Result.Success -> dataResult.data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.domain.wallets.models
|
||||
package com.tangem.domain.wallets.models.errors
|
||||
|
||||
sealed class GetUserWalletError {
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ import arrow.core.Either
|
|||
import arrow.core.raise.either
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
|
||||
/**
|
||||
* Use case for getting selected wallet.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import arrow.core.Either
|
|||
import arrow.core.raise.either
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import com.tangem.domain.common.wallets.requireUserWalletsSync
|
|||
import com.tangem.domain.core.utils.EitherFlow
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue