Updated on 2026-08-14
This commit is contained in:
commit
31a8ee5dd4
18 changed files with 579 additions and 366 deletions
|
|
@ -12,7 +12,8 @@ import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
|
|||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
|
|
@ -42,8 +43,26 @@ object AddressBookDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase {
|
||||
return ValidateContactNameUseCase(repository = repository)
|
||||
fun provideContactSignatureVerifier(
|
||||
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
): ContactSignatureVerifier {
|
||||
return ContactSignatureVerifier(
|
||||
verifyMessages = verifyMessagesUseCase,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContactNameValidator(
|
||||
repository: AddressBookRepository,
|
||||
contactSignatureVerifier: ContactSignatureVerifier,
|
||||
): ContactNameValidator {
|
||||
return ContactNameValidator(
|
||||
repository = repository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
@ -56,13 +75,11 @@ object AddressBookDomainModule {
|
|||
@Singleton
|
||||
fun provideGetVerifiedContactsInteractor(
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
contactSignatureVerifier: ContactSignatureVerifier,
|
||||
): GetVerifiedContactsInteractor {
|
||||
return GetVerifiedContactsInteractor(
|
||||
getContacts = getContactsUseCase,
|
||||
verifyMessages = verifyMessagesUseCase,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -70,13 +87,13 @@ object AddressBookDomainModule {
|
|||
@Singleton
|
||||
fun provideSaveContactInteractor(
|
||||
repository: AddressBookRepository,
|
||||
validateContactNameUseCase: ValidateContactNameUseCase,
|
||||
contactNameValidator: ContactNameValidator,
|
||||
signUseCase: SignUseCase,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
): SaveContactInteractor {
|
||||
return SaveContactInteractor(
|
||||
repository = repository,
|
||||
validateContactName = validateContactNameUseCase,
|
||||
validateContactName = contactNameValidator,
|
||||
signUseCase = signUseCase,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2275,6 +2275,8 @@
|
|||
<string name="warning_hedera_missing_token_association_message_brief">このトークンを受け取るには、Hederaアカウントに関連付ける必要があります。</string>
|
||||
<string name="warning_hedera_missing_token_association_title">トークンを関連付ける</string>
|
||||
<string name="warning_hedera_token_association_not_enough_hbar_message">%sが不足しています。このトークンを関連付けるには、Hederaアカウントに資金を追加してください。</string>
|
||||
<string name="warning_incomplete_backup_notification_message">カードの有効化が正常に完了していないことが判明しました。原因として、お使いの端末のNFC機能の不具合、またはカードをスマートフォンに正しくかざせていない可能性があります。詳しくはサポートまでお問い合わせください。</string>
|
||||
<string name="warning_incomplete_backup_notification_title">対応が必要です。ウォレットは使用しないでください。</string>
|
||||
<string name="warning_kaspa_unfinished_token_transaction_discard_message">取引をキャンセルしてもよろしいですか? 取引を再試行することはできません</string>
|
||||
<string name="warning_kaspa_unfinished_token_transaction_message">%1$s %2$sの取引が完了しませんでした。もう一度試して完了してください。</string>
|
||||
<string name="warning_kaspa_unfinished_token_transaction_title">未完了の取引があります</string>
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
<string name="address_book_contact">Contact</string>
|
||||
<string name="address_book_contact_name">Contact name</string>
|
||||
<string name="address_book_copy_address">Copy address</string>
|
||||
<string name="address_book_create_success_message">Contact added</string>
|
||||
<string name="address_book_create_success_message">Contact saved</string>
|
||||
<string name="address_book_creating_error">Couldn\'t create contact. Please try again later.</string>
|
||||
<string name="address_book_delete_contact">Delete contact</string>
|
||||
<string name="address_book_delete_contact_description">This contact will be deleted from all your address books</string>
|
||||
|
|
@ -1285,12 +1285,14 @@
|
|||
<string name="onramp_title_available_from">Available from</string>
|
||||
<string name="onramp_title_available_up_to">Available up to</string>
|
||||
<string name="onramp_title_you_get">You get</string>
|
||||
<string name="onramp_token_is_not_supported_banner_subtitle">This token is not supported. Please choose a different token to buy.</string>
|
||||
<string name="onramp_tos_external_providers">Service is provided by an external provider. \nTangem is not responsible.</string>
|
||||
<string name="onramp_transaction_status_footer_text">You can close this screen and check the transaction status on the token details screen.</string>
|
||||
<string name="onramp_up_to_rate">Up to</string>
|
||||
<string name="onramp_via">Via</string>
|
||||
<string name="onramp_via_provider">via %s</string>
|
||||
<string name="onramp_you_will_pay_title">You will pay</string>
|
||||
<string name="onramps_token_is_not_supported_banner_title">%s is not supported</string>
|
||||
<string name="organize_tokens_group">Group</string>
|
||||
<string name="organize_tokens_menu_group_by_networks">Group by networks</string>
|
||||
<string name="organize_tokens_menu_sort_by_balance">Sort by balance</string>
|
||||
|
|
@ -2077,6 +2079,9 @@
|
|||
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
|
||||
<string name="token_market_metrics_no_data">N/A</string>
|
||||
<string name="token_receive_show_qr_code_title">Show QR code</string>
|
||||
<string name="token_summary_can_not_load_token">Can’t load data of the token</string>
|
||||
<string name="token_summary_go_to_swap_button">Go to swap</string>
|
||||
<string name="token_summary_title">Token summary</string>
|
||||
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>
|
||||
<string name="token_swap_changelly_promotion_title">Swap with Changelly, %s fees</string>
|
||||
<string name="token_swap_promotion_button">Swap now</string>
|
||||
|
|
|
|||
|
|
@ -1,65 +1,20 @@
|
|||
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.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.addressbook.verification.ContactSignatureVerifier
|
||||
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
|
||||
|
||||
class GetVerifiedContactsInteractor(
|
||||
private val getContacts: GetContactsUseCase,
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier,
|
||||
) {
|
||||
|
||||
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
|
||||
fun getVerifiedContacts(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(addresses = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
contactSignatureVerifier.verifyContacts(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
|
||||
|
||||
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
|
||||
val wellFormed = entries.mapNotNull { entry ->
|
||||
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
|
||||
}
|
||||
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
|
||||
val signatures = wellFormed.map { (_, signature) -> signature }
|
||||
|
||||
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
.map { flags ->
|
||||
val validIds = wellFormed
|
||||
.filterIndexed { index, _ -> flags[index] }
|
||||
.mapTo(HashSet()) { (entry, _) -> entry.id }
|
||||
|
||||
AddressEntriesVerification(
|
||||
valid = entries.filter { it.id in validIds },
|
||||
invalid = entries.filterNot { it.id in validIds },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ 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.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
|
|
@ -22,7 +22,7 @@ import java.util.UUID
|
|||
|
||||
class SaveContactInteractor(
|
||||
private val repository: AddressBookRepository,
|
||||
private val validateContactName: ValidateContactNameUseCase,
|
||||
private val validateContactName: ContactNameValidator,
|
||||
private val signUseCase: SignUseCase,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
|
@ -34,7 +34,7 @@ class SaveContactInteractor(
|
|||
addresses: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val userWalletId = userWallet.walletId
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
val validName = validateContactName.validate(userWalletId, name)
|
||||
.mapLeft(SaveContactError::Name)
|
||||
.bind()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable
|
|||
*
|
||||
* The only way to obtain an instance is the validating [invoke] factory, which enforces the
|
||||
* address-book naming rules. Uniqueness within a wallet is **not** enforced here — it requires
|
||||
* access to the repository and lives in `ValidateContactNameUseCase`.
|
||||
* access to the repository and lives in `ContactNameValidator`.
|
||||
*/
|
||||
@Serializable
|
||||
@ConsistentCopyVisibility
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within
|
||||
* the wallet.
|
||||
*/
|
||||
class ValidateContactNameUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
walletId: UserWalletId,
|
||||
name: String,
|
||||
): Either<ContactNameValidationError, ContactName> = either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft(ContactNameValidationError::Format)
|
||||
.bind()
|
||||
|
||||
val contacts = repository.getContacts(walletId).first()
|
||||
val isDuplicate = contacts.any { contact ->
|
||||
contact.name.value.equals(validName.value, ignoreCase = true)
|
||||
}
|
||||
ensure(!isDuplicate) { ContactNameValidationError.Duplicate }
|
||||
|
||||
validName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.domain.addressbook.validation
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within the
|
||||
* wallet.
|
||||
*
|
||||
* Uniqueness is enforced only against **verified** contacts (see [ContactSignatureVerifier.isNameVerified]):
|
||||
* a spoofed or tampered contact synced from another device must not be able to reserve a name. Reads the
|
||||
* local snapshot via [AddressBookRepository.getContactsSync] (validation runs on live keystrokes) and
|
||||
* filters to same-name contacts before verifying, so signature checks fire only on an actual collision.
|
||||
*/
|
||||
class ContactNameValidator(
|
||||
private val repository: AddressBookRepository,
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier,
|
||||
) {
|
||||
|
||||
suspend fun validate(walletId: UserWalletId, name: String): Either<ContactNameValidationError, ContactName> =
|
||||
either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft(ContactNameValidationError::Format)
|
||||
.bind()
|
||||
|
||||
val sameName = repository.getContactsSync(walletId)
|
||||
.filter { it.name.value.equals(validName.value, ignoreCase = true) }
|
||||
val isDuplicate = sameName.any { contactSignatureVerifier.isNameVerified(it) }
|
||||
ensure(!isDuplicate) { ContactNameValidationError.Duplicate }
|
||||
|
||||
validName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.domain.addressbook.verification
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.domain.addressbook.model.AddressEntriesVerification
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
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.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.hexToBytesOrNull
|
||||
|
||||
class ContactSignatureVerifier(
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
) {
|
||||
|
||||
suspend fun verifyContacts(contacts: List<Contact>): List<VerifiedContact> {
|
||||
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
|
||||
return contacts.mapNotNull { contact ->
|
||||
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
|
||||
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isNameVerified(contact: Contact): Boolean {
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.firstOrNull { it.walletId == contact.walletId } ?: return false
|
||||
return verify(userWallet, contact).getOrNull()?.valid?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
|
||||
|
||||
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
|
||||
val wellFormed = entries.mapNotNull { entry ->
|
||||
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
|
||||
}
|
||||
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
|
||||
val signatures = wellFormed.map { (_, signature) -> signature }
|
||||
|
||||
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
.map { flags ->
|
||||
val validIds = wellFormed
|
||||
.filterIndexed { index, _ -> flags[index] }
|
||||
.mapTo(HashSet()) { (entry, _) -> entry.id }
|
||||
|
||||
AddressEntriesVerification(
|
||||
valid = entries.filter { it.id in validIds },
|
||||
invalid = entries.filterNot { it.id in validIds },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
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
|
||||
|
|
@ -10,19 +8,14 @@ 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.addressbook.verification.ContactSignatureVerifier
|
||||
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.coVerify
|
||||
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
|
||||
|
|
@ -34,157 +27,37 @@ import org.junit.jupiter.api.TestInstance
|
|||
class GetVerifiedContactsInteractorTest {
|
||||
|
||||
private val getContacts: GetContactsUseCase = mockk()
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
|
||||
|
||||
private val interactor = GetVerifiedContactsInteractor(
|
||||
getContacts = getContacts,
|
||||
verifyMessages = verifyMessages,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
|
||||
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)
|
||||
clearMocks(getContacts, contactSignatureVerifier)
|
||||
}
|
||||
|
||||
@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(addresses = 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.addresses[0]),
|
||||
expectedPayload(contact, contact.addresses[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.addresses).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.addresses).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest {
|
||||
fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
stubContacts(contact)
|
||||
val verified = VerifiedContact(contact = contact, invalidEntries = emptyList())
|
||||
every { getContacts(query = "query", userWalletId = walletId) } returns flowOf(listOf(contact))
|
||||
coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns listOf(verified)
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
val result = interactor.getVerifiedContacts(query = "query", userWalletId = walletId).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
assertThat(result).containsExactly(verified)
|
||||
coVerify(exactly = 1) { contactSignatureVerifier.verifyContacts(listOf(contact)) }
|
||||
}
|
||||
|
||||
@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(
|
||||
private fun contact(): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName("Alice").getOrNull()),
|
||||
|
|
@ -192,18 +65,15 @@ class GetVerifiedContactsInteractorTest {
|
|||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addresses = entries.toList(),
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-1"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
networkName = "Ethereum",
|
||||
memo = null,
|
||||
signature = "AABB",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -14,10 +14,9 @@ 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.validation.ContactNameValidator
|
||||
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
|
||||
|
|
@ -27,7 +26,6 @@ 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
|
||||
|
|
@ -39,13 +37,14 @@ import java.security.MessageDigest
|
|||
internal class SaveContactInteractorTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val contactNameValidator: ContactNameValidator = mockk()
|
||||
private val signUseCase: SignUseCase = mockk()
|
||||
private val timestampProvider: IsoTimestampProvider = mockk {
|
||||
every { now() } returns NEW_TIMESTAMP
|
||||
}
|
||||
private val interactor = SaveContactInteractor(
|
||||
repository = repository,
|
||||
validateContactName = ValidateContactNameUseCase(repository),
|
||||
validateContactName = contactNameValidator,
|
||||
signUseCase = signUseCase,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
|
@ -57,7 +56,7 @@ internal class SaveContactInteractorTest {
|
|||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, signUseCase, answers = false)
|
||||
clearMocks(repository, contactNameValidator, signUseCase, answers = false)
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
@ -69,7 +68,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
|
||||
signatures.right()
|
||||
|
|
@ -95,7 +94,7 @@ internal class SaveContactInteractorTest {
|
|||
fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val twoEntries = listOf(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null),
|
||||
|
|
@ -129,7 +128,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ internal class SaveContactInteractorTest {
|
|||
every { walletId } returns userWallet.walletId
|
||||
every { wallets } returns null
|
||||
}
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
|
||||
|
|
@ -164,7 +163,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
SignHashesError.SigningFailed(message = "canceled").left()
|
||||
|
||||
|
|
@ -180,7 +179,8 @@ internal class SaveContactInteractorTest {
|
|||
@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")))
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, "alice") } returns
|
||||
ContactNameValidationError.Duplicate.left()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
|
||||
|
|
@ -194,7 +194,8 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, "") } returns
|
||||
ContactNameValidationError.Format(ContactName.Error.Empty).left()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
|
||||
|
|
@ -208,7 +209,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
listOf(byteArrayOf(0x01)).right()
|
||||
coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left()
|
||||
|
|
@ -221,8 +222,9 @@ internal class SaveContactInteractorTest {
|
|||
.isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict))
|
||||
}
|
||||
|
||||
private fun stubNoExistingContacts() {
|
||||
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
|
||||
private fun stubValidName(name: String) {
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, name) } returns
|
||||
requireNotNull(ContactName(name).getOrNull()).right()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +263,7 @@ internal class SaveContactInteractorTest {
|
|||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addresses.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
coVerify(exactly = 0) { contactNameValidator.validate(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,82 +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.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 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 ValidateContactNameUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = ValidateContactNameUseCase(repository)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format error is propagated`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
|
||||
val result = useCase(walletId, name = "")
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate name in same wallet is rejected case-insensitively`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "alice")
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique name is accepted`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "Bob")
|
||||
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Bob")
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
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",
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package com.tangem.domain.addressbook.validation
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
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.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
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 ContactNameValidatorTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk()
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
|
||||
|
||||
private val validator = ContactNameValidator(
|
||||
repository = repository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, contactSignatureVerifier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank name WHEN validate THEN format error is propagated`() = runTest {
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "")
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-name verified contact WHEN validate THEN Duplicate rejected case-insensitively`() = runTest {
|
||||
// Arrange
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
coEvery { contactSignatureVerifier.isNameVerified(any()) } returns true
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "alice")
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-name but unverified spoofed contact WHEN validate THEN name is accepted`() = runTest {
|
||||
// Arrange — a contact synced from another device whose signature does not verify must not reserve a name
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
coEvery { contactSignatureVerifier.isNameVerified(any()) } returns false
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "alice")
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no same-name contacts WHEN validate THEN accepted without verifying`() = runTest {
|
||||
// Arrange
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "Bob")
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Bob")
|
||||
coVerify(exactly = 0) { contactSignatureVerifier.isNameVerified(any()) }
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
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",
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "AABB",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
package com.tangem.domain.addressbook.verification
|
||||
|
||||
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.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.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
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ContactSignatureVerifierTest {
|
||||
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
|
||||
private val verifier = ContactSignatureVerifier(
|
||||
verifyMessages = verifyMessages,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val userWallet: UserWallet = mockk { every { walletId } returns this@ContactSignatureVerifierTest.walletId }
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(verifyMessages, userWalletsListRepository)
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class VerifyContacts {
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed entries WHEN verifyContacts 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)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = listOf(valid)),
|
||||
invalidEntries = listOf(invalid),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN verifyContacts 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"),
|
||||
)
|
||||
val messagesSlot = slot<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
|
||||
} returns listOf(true, true).right()
|
||||
|
||||
// Act
|
||||
verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addresses[0]),
|
||||
expectedPayload(contact, contact.addresses[1]),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN some entries fail verification WHEN verifyContacts 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)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalidEntries).containsExactly(invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed signature WHEN verifyContacts 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)
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
|
||||
} returns listOf(true).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result.contact.addresses).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN verifyContacts THEN keeps contact without verifying`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN verifyContacts THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN verifyContacts THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class IsNameVerified {
|
||||
|
||||
@Test
|
||||
fun `GIVEN at least one valid entry WHEN isNameVerified THEN true`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(false, true).right()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN all entries invalid WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(false).right()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
addresses = 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
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import com.tangem.domain.addressbook.model.Contact
|
|||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
|
||||
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -55,7 +55,7 @@ internal class EditContactModel @Inject constructor(
|
|||
private val resultHolder: AddressBookResultHolder,
|
||||
private val messageSender: UiMessageSender,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val validateContactNameUseCase: ValidateContactNameUseCase,
|
||||
private val contactNameValidator: ContactNameValidator,
|
||||
private val saveContactInteractor: SaveContactInteractor,
|
||||
private val getContactByIdUseCase: GetContactByIdUseCase,
|
||||
private val deleteContactUseCase: DeleteContactUseCase,
|
||||
|
|
@ -232,6 +232,7 @@ internal class EditContactModel @Inject constructor(
|
|||
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
|
||||
) { name, walletId -> name to walletId }
|
||||
.mapLatest { (name, walletId) -> validateName(name, walletId) }
|
||||
.flowOn(dispatchers.default)
|
||||
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
|
@ -417,7 +418,7 @@ internal class EditContactModel @Inject constructor(
|
|||
private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? {
|
||||
if (name.isBlank()) return null
|
||||
if (name == loadedContact.value?.name?.value) return null
|
||||
val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null
|
||||
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
|
||||
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
|
||||
return ContactNameErrorConverter().convert(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,12 +58,16 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val selectedWalletId = MutableStateFlow<String?>(value = null)
|
||||
|
||||
private val allContacts: SharedFlow<List<VerifiedContact>> =
|
||||
getVerifiedContactsInteractor(query = "", userWalletId = null)
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
|
||||
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
|
||||
|
||||
init {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null)
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
|||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
|
||||
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
|
|
@ -56,7 +56,7 @@ internal class EditContactModelTest {
|
|||
private val resultHolder = AddressBookResultHolder()
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
|
||||
private val validateContactNameUseCase: ValidateContactNameUseCase = mockk()
|
||||
private val contactNameValidator: ContactNameValidator = mockk()
|
||||
private val saveContactInteractor: SaveContactInteractor = mockk()
|
||||
private val getContactByIdUseCase: GetContactByIdUseCase = mockk()
|
||||
private val deleteContactUseCase: DeleteContactUseCase = mockk()
|
||||
|
|
@ -75,7 +75,7 @@ internal class EditContactModelTest {
|
|||
fun setUp() {
|
||||
// Default: no wallets loaded, name always valid. Individual tests override as needed.
|
||||
setupWallets(wallets = emptyList(), selected = null)
|
||||
coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right()
|
||||
coEvery { contactNameValidator.validate(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right()
|
||||
every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher
|
||||
every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData
|
||||
// No existing contact by default; a StateFlow<null> never surfaces a contact and never completes.
|
||||
|
|
@ -396,7 +396,7 @@ internal class EditContactModelTest {
|
|||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
coEvery {
|
||||
validateContactNameUseCase(any(), any())
|
||||
contactNameValidator.validate(any(), any())
|
||||
} returns ContactNameValidationError.Duplicate.left()
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
|
@ -434,7 +434,7 @@ internal class EditContactModelTest {
|
|||
val walletB = createWallet(id = "bb", name = "Wallet B")
|
||||
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
|
||||
coEvery {
|
||||
validateContactNameUseCase(walletB.walletId, "Satoshi")
|
||||
contactNameValidator.validate(walletB.walletId, "Satoshi")
|
||||
} returns ContactNameValidationError.Duplicate.left()
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
|
@ -489,7 +489,7 @@ internal class EditContactModelTest {
|
|||
// Arrange
|
||||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
coEvery { validateContactNameUseCase(any(), any()) } returns ContactNameValidationError.Duplicate.left()
|
||||
coEvery { contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left()
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -927,7 +927,7 @@ internal class EditContactModelTest {
|
|||
resultHolder = resultHolder,
|
||||
messageSender = messageSender,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
validateContactNameUseCase = validateContactNameUseCase,
|
||||
contactNameValidator = contactNameValidator,
|
||||
saveContactInteractor = saveContactInteractor,
|
||||
getContactByIdUseCase = getContactByIdUseCase,
|
||||
deleteContactUseCase = deleteContactUseCase,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ internal class AddressBookListModelTest {
|
|||
@Test
|
||||
fun `GIVEN feature just opened WHEN contacts not yet loaded THEN Loading state`() = runTest {
|
||||
// Arrange — the interactor has not emitted yet (books still syncing).
|
||||
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns emptyFlow()
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns emptyFlow()
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
|
|
@ -73,7 +73,7 @@ internal class AddressBookListModelTest {
|
|||
@Test
|
||||
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
|
||||
// Arrange
|
||||
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
|
||||
|
||||
// Act
|
||||
|
|
@ -89,7 +89,7 @@ internal class AddressBookListModelTest {
|
|||
@Test
|
||||
fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest {
|
||||
// Arrange
|
||||
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns flowOf(emptyList())
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(emptyList())
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
|
|
@ -103,7 +103,7 @@ internal class AddressBookListModelTest {
|
|||
fun `GIVEN default mode WHEN contact clicked THEN editor opened with contact id`() = runTest {
|
||||
// Arrange
|
||||
var clickedId: String? = null
|
||||
every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
|
||||
val model = createModel(
|
||||
testScope = this,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue