Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 14:19:18 +03:00
commit 59228972a9
1705 changed files with 82417 additions and 14905 deletions

View file

@ -5,17 +5,29 @@ plugins {
}
dependencies {
api(projects.domain.common)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Core modules
api(projects.core.utils)
// endregion
// region Domain
runtimeOnly(projects.domain.common)
api(projects.domain.core)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.wallets.models)
api(projects.domain.yieldSupply.models)
// endregion
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
// region Test libraries
// region Tests
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
// endregion

View file

@ -9,6 +9,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
@ -32,6 +33,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module
@ -227,4 +229,10 @@ internal object AccountStatusUseCaseModule {
dispatchers = dispatchers,
)
}
@Provides
@IntoSet
fun provideCryptoCurrencyMetadataUserWalletDataCleaner(
impl: CryptoCurrencyMetadataCleaner,
): UserWalletDataCleaner = impl
}

View file

@ -1,11 +1,14 @@
package com.tangem.domain.account.status.utils
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
@ -27,7 +30,32 @@ class CryptoCurrencyMetadataCleaner(
private val stakingCleaner: StakingCleaner,
private val nftCleaner: NFTCleaner,
private val dispatchers: CoroutineDispatcherProvider,
) {
) : UserWalletDataCleaner {
/**
* Removes all currency metadata (networks, staking balances) for the given wallets.
*
* Invoked on wallet deletion, where the concrete currencies are no longer available, so cleanup happens by
* wallet id in bulk instead of per-currency.
*
* @param userWalletIds The IDs of the deleted user wallets.
*/
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
// Best-effort: isolate failures so a failing cleaner does not cancel the other.
withContext(dispatchers.default) {
awaitAll(
async { clearSafely(target = "networks") { networksCleaner.clear(userWalletIds) } },
async { clearSafely(target = "staking") { stakingCleaner.clear(userWalletIds) } },
)
}
}
private suspend fun clearSafely(target: String, clear: suspend () -> Unit) {
runSuspendCatching { clear() }
.onFailure { TangemLogger.e("Failed to clear $target metadata", it) }
}
/**
* Cleans up data for a single cryptocurrency in the specified user wallet.

View file

@ -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

View file

@ -19,7 +19,7 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoCurrencyCleanerTest {
class CryptoCurrencyMetadataCleanerTest {
private val networksCleaner: NetworksCleaner = mockk(relaxUnitFun = true)
private val stakingCleaner: StakingCleaner = mockk(relaxUnitFun = true)
@ -71,4 +71,31 @@ class CryptoCurrencyCleanerTest {
nftCleaner(userWalletId = userWalletId, networks = setOf(coin.network, token.network))
}
}
@Test
fun `GIVEN wallets WHEN clear THEN networks and staking cleared once with all ids`() = runTest {
// Arrange
val ids = listOf(userWalletId, UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) {
networksCleaner.clear(ids)
stakingCleaner.clear(ids)
}
}
@Test
fun `GIVEN empty list WHEN clear THEN no cleaners are called`() = runTest {
// Act
cleaner.clear(userWalletIds = emptyList())
// Assert
coVerify(inverse = true) {
networksCleaner.clear(any())
stakingCleaner.clear(any())
}
}
}

View file

@ -11,20 +11,35 @@ android {
dependencies {
api(projects.domain.core)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.arrow.core)
api(deps.jodatime)
// endregion
// region Core modules
implementation(projects.core.utils)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.tokens)
api(projects.domain.transaction)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
implementation(projects.domain.transaction)
implementation(projects.domain.tokens)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)
// region Test libraries
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -10,6 +10,7 @@ import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.extensions.hexToBytesOrNull
import com.tangem.utils.extensions.toHexString
import com.tangem.utils.logging.TangemLogger
import kotlinx.serialization.json.Json
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
@ -26,7 +27,7 @@ import javax.crypto.spec.SecretKeySpec
* stored. Each encryption uses a fresh random 12-byte nonce, so encrypting the same book twice
* produces different blobs that both decrypt back to the original.
*
* The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `auth_tag` field
* The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `authTag` field
* (Java appends it to the ciphertext; this class splits it out and re-joins it on decrypt). A
* tampered ciphertext or tag fails the tag check and surfaces as
* [AddressBookCryptoError.DecryptionFailed].
@ -35,14 +36,13 @@ class AddressBookCipher {
private val json = Json { ignoreUnknownKeys = true }
private val secureRandom = SecureRandom()
private val logger = TangemLogger.withTag(LOG_TAG)
fun encrypt(
addressBook: AddressBook,
userWallet: UserWallet,
updatedAt: DateTime,
): Either<AddressBookCryptoError, AddressBookBlob> = either {
ensure(addressBook.walletId == userWallet.walletId) { AddressBookCryptoError.WalletMismatch }
val aesKey = deriveKey(userWallet)
val plaintext = json.encodeToString(AddressBook.serializer(), addressBook).toByteArray(Charsets.UTF_8)
@ -55,29 +55,102 @@ class AddressBookCipher {
val authTag = cipherWithTag.copyOfRange(fromIndex = tagOffset, toIndex = cipherWithTag.size)
AddressBookBlob(
walletId = addressBook.walletId.stringValue,
walletId = userWallet.walletId.stringValue,
updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(),
nonce = nonce.toHexString().lowercase(),
ciphertext = ciphertext.toHexString().lowercase(),
authTag = authTag.toHexString().lowercase(),
)
).also { blob ->
// Metadata only — the plaintext (contact names/addresses/memos) is never logged.
logger.i(
"Encrypted address book for wallet ${blob.walletId}: contacts=${addressBook.contacts.size}, " +
"plaintextBytes=${plaintext.size}, " +
"nonceLen=${blob.nonce.length}, ciphertextLen=${blob.ciphertext.length}, " +
"authTagLen=${blob.authTag.length}",
)
}
}
fun decrypt(blob: AddressBookBlob, userWallet: UserWallet): Either<AddressBookCryptoError, AddressBook> = either {
ensure(blob.walletId == userWallet.walletId.stringValue) { AddressBookCryptoError.WalletMismatch }
// Metadata only — helps QA correlate a failing blob with what was received from the backend/other platform.
logger.i(
"Decrypting address book for wallet ${blob.walletId}: " +
"updatedAt=${blob.updatedAt}, nonceLen=${blob.nonce.length}, " +
"ciphertextLen=${blob.ciphertext.length}, authTagLen=${blob.authTag.length}",
)
ensure(blob.walletId == userWallet.walletId.stringValue) {
logger.e(
"Wallet mismatch decrypting address book: blob wallet=${blob.walletId}, " +
"target wallet=${userWallet.walletId.stringValue}",
)
AddressBookCryptoError.WalletMismatch
}
val aesKey = deriveKey(userWallet)
val nonce = blob.nonce.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val authTag = blob.authTag.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
val nonce = blob.nonce.hexToBytesOrNull() ?: raiseNonHex(blob, field = "nonce", value = blob.nonce)
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raiseNonHex(
blob = blob,
field = "ciphertext",
value = blob.ciphertext,
)
val authTag = blob.authTag.hexToBytesOrNull() ?: raiseNonHex(blob, field = "authTag", value = blob.authTag)
val plaintext = runCatching {
cipher(Cipher.DECRYPT_MODE, aesKey, nonce).doFinal(ciphertext + authTag)
}.getOrElse { raise(AddressBookCryptoError.DecryptionFailed) }
}.getOrElse { error ->
logger.e(
"Failed to AES-GCM decrypt address book for wallet ${blob.walletId} " +
"(nonceBytes=${nonce.size}, ciphertextBytes=${ciphertext.size}, authTagBytes=${authTag.size}): " +
"${error.safeDescription()}. Usually a wrong key/nonce/tag or a corrupted blob.",
error,
)
raise(AddressBookCryptoError.DecryptionFailed)
}
runCatching {
json.decodeFromString(AddressBook.serializer(), plaintext.toString(Charsets.UTF_8))
}.getOrElse { raise(AddressBookCryptoError.MalformedBlob) }
}.getOrElse { error ->
// Decryption succeeded, so this is a payload schema/format mismatch — the prime suspect for a book
// reason (missing/extra field, wrong type, JSON path) but strips any raw plaintext the exception echoes.
logger.e(
"Failed to parse decrypted address book for wallet ${blob.walletId} " +
"(plaintextBytes=${plaintext.size}): ${error.safeDescription()}. " +
"Decryption OK → cross-platform payload schema/format mismatch.",
)
raise(AddressBookCryptoError.MalformedBlob)
}.also { addressBook ->
logger.i(
messageString = "Decrypted address book for wallet ${blob.walletId}: " +
"contacts=${addressBook.contacts.size}",
)
}
}
private fun Raise<AddressBookCryptoError>.raiseNonHex(
blob: AddressBookBlob,
field: String,
value: String,
): Nothing {
logger.e(
"Address book blob has non-hex $field for wallet ${blob.walletId} " +
"(${field}Len=${value.length}).",
)
raise(AddressBookCryptoError.DecryptionFailed)
}
/**
* A log-safe, one-line description of a failure: exception type + message, with any raw decrypted payload
* stripped. kotlinx.serialization appends the offending input after a `JSON input:` marker; everything before
* it (the reason and the `at path: ` location) is structural and safe to log.
*/
private fun Throwable.safeDescription(): String {
val safeMessage = message.orEmpty().substringBefore("JSON input:").trim()
return buildString {
append(this@safeDescription::class.simpleName)
if (safeMessage.isNotEmpty()) append(": ").append(safeMessage)
}
}
private fun Raise<AddressBookCryptoError>.deriveKey(userWallet: UserWallet): ByteArray {
@ -93,6 +166,7 @@ class AddressBookCipher {
}
private companion object {
const val LOG_TAG = "AddressBook"
const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"
const val AES_ALGORITHM = "AES"
const val NONCE_SIZE_BYTES = 12

View file

@ -0,0 +1,27 @@
package com.tangem.domain.addressbook.error
/**
* Failure of a backend address-book operation (`PUT /address-books/{walletId}` or
* `POST /address-books/sync`). The backend is the source of truth, so when one of these is raised the
* local blob is left untouched.
*/
sealed interface AddressBookSyncError {
/** Etag mismatch on update (HTTP 412) — the book was changed elsewhere. */
data object Conflict : AddressBookSyncError
/** The wallet does not exist on the backend (HTTP 404). */
data object NotFound : AddressBookSyncError
/** Invalid API key (HTTP 401). */
data object Unauthorized : AddressBookSyncError
/** Malformed request or exceeded the wallet limit (HTTP 400). */
data object BadRequest : AddressBookSyncError
/** No network or the request could not be completed. */
data object Network : AddressBookSyncError
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
data object Unknown : AddressBookSyncError
}

View file

@ -1,10 +1,12 @@
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
data class Backend(val error: AddressBookSyncError) : SaveContactError
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.addressbook.interactor
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetVerifiedContactsInteractor(
private val getContacts: GetContactsUseCase,
private val contactSignatureVerifier: ContactSignatureVerifier,
) {
fun getVerifiedContacts(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
return getContacts(query, userWalletId).map { contacts ->
contactSignatureVerifier.verifyContacts(contacts)
}
}
}

View file

@ -0,0 +1,106 @@
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.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
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: ContactNameValidator,
private val signUseCase: SignUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
suspend fun createContact(
userWallet: UserWallet,
name: String,
iconColor: String,
addresses: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName.validate(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,
addresses = addresses,
)
val signed = signAddresses(userWallet, contact)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
.mapLeft(SaveContactError::Backend)
.bind()
signed
}
suspend fun updateContact(
userWallet: UserWallet,
contact: Contact,
name: String,
iconColor: String,
addresses: 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,
addresses = addresses,
updatedAt = timestampProvider.now(),
)
val signed = signAddresses(userWallet, updated)
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
.mapLeft(SaveContactError::Backend)
.bind()
signed
}
private suspend fun signAddresses(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> =
either {
val entries = contact.addresses
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(addresses = signedEntries)
}
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
val payload = buildAddressEntryPayload(contact, entry)
return MessageDigest.getInstance("SHA-256").digest(payload)
}
}

View file

@ -1,15 +1,10 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* All [Contact]s of a single wallet. This is the plaintext payload that
* [com.tangem.domain.addressbook.crypto.AddressBookCipher] encrypts into an
* [AddressBookBlob] and reconstructs on decryption.
*/
@Serializable
data class AddressBook(
val walletId: UserWalletId,
@SerialName("contacts")
val contacts: List<Contact>,
)

View file

@ -19,18 +19,24 @@ import kotlinx.serialization.Serializable
* "updatedAt": "2026-05-22T09:00:00.000Z",
* "nonce": "",
* "ciphertext": "",
* "auth_tag": ""
* "authTag": ""
* }
* ```
*/
@Serializable
data class AddressBookBlob(
@SerialName("version")
val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY]
@SerialName("walletId")
val walletId: String,
@SerialName("updatedAt")
val updatedAt: String,
@SerialName("nonce")
val nonce: String,
@SerialName("ciphertext")
val ciphertext: String,
@SerialName("auth_tag") val authTag: String,
@SerialName("authTag")
val authTag: String,
) {
companion object {

View file

@ -1,14 +1,21 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.serialization.NetworkRawIdAsStringSerializer
import com.tangem.domain.models.network.Network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A single saved address belonging to a [Contact]. */
@Serializable
data class AddressEntry(
@SerialName("id")
val id: AddressEntryId,
@SerialName("address")
val address: String,
@SerialName("networkId")
@Serializable(with = NetworkRawIdAsStringSerializer::class)
val networkId: Network.RawID,
val memo: String?,
@SerialName("memo")
val memo: String? = null,
@SerialName("signature")
val signature: String,
)

View file

@ -1,6 +1,9 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.serialization.ContactNameAsStringSerializer
import com.tangem.domain.addressbook.model.serialization.UserWalletIdAsStringSerializer
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
@ -12,10 +15,22 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class Contact(
@SerialName("id")
val id: ContactId,
@SerialName("walletId")
@Serializable(with = UserWalletIdAsStringSerializer::class)
val walletId: UserWalletId,
@SerialName("name")
@Serializable(with = ContactNameAsStringSerializer::class)
val name: ContactName,
@SerialName("icon")
val icon: String,
@SerialName("iconColor")
val iconColor: String,
@SerialName("createdAt")
val createdAt: String,
@SerialName("updatedAt")
val updatedAt: String,
val addressEntries: List<AddressEntry>,
@SerialName("addresses")
val addresses: List<AddressEntry>,
)

View file

@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.model
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.addressbook.model.ContactName.Companion.invoke
import kotlinx.serialization.Serializable
/**
@ -10,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
@ -31,11 +32,24 @@ data class ContactName private constructor(val value: String) {
companion object {
const val MIN_LENGTH = 1
private const val MIN_LENGTH = 1
const val MAX_LENGTH = 50
/** Letters, numbers and spaces only — forbids emoji, new lines, tabs, special symbols and html/scripts. */
private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$")
/**
* Allows letters of any locale (`\p{L}`) and their combining marks (`\p{M}`, which also covers emoji
* variation selectors and keycap marks), digits (`\p{N}`), a regular space, and emoji symbols (`\p{So}`,
* including flags / regional indicators), emoji skin-tone modifiers (`\p{Sk}`) and the zero-width joiner
* (U+200D) used in emoji sequences.
*
* Everything else is rejected, which covers the forbidden set: line breaks, tabs and other control
* characters, invisible/format unicode (zero-width spaces, BOM, ), exotic spaces, and HTML/script symbols.
*
* The leading lookahead requires at least one visible "base" character (letter / digit / emoji symbol), so a
* name made up only of zero-width joiners, combining marks, modifiers or spaces (i.e. effectively invisible)
* is rejected.
*/
private val allowedPattern =
Regex("^(?=.*[\\p{L}\\p{N}\\p{So}])[\\p{L}\\p{M}\\p{N}\\p{So}\\p{Sk}\\u0020\\u200D]+$")
operator fun invoke(value: String): Either<Error, ContactName> = either {
val trimmed = value.trim()

View file

@ -0,0 +1,12 @@
package com.tangem.domain.addressbook.model
/**
* @property contact the contact carrying only the entries whose signatures verified against the
* wallet what should be shown to the user.
* @property invalidEntries entries that failed verification (tampered, signed by another wallet, or
* malformed). Hidden from the UI but kept for analytics.
*/
data class VerifiedContact(
val contact: Contact,
val invalidEntries: List<AddressEntry>,
)

View file

@ -0,0 +1,56 @@
package com.tangem.domain.addressbook.model.serialization
import arrow.core.getOrElse
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/*
* The encrypted address-book payload is a cross-platform (iOS) contract. It carries wallet id, contact
* name and network id as bare strings, so the wrapper domain types must serialize to their underlying
* string rather than the default `{"field": }` object. These serializers are applied per-property via
* `@Serializable(with = )`, leaving the global serialization of the shared types untouched.
*/
/** Serializes [UserWalletId] as its bare [UserWalletId.stringValue]. */
internal object UserWalletIdAsStringSerializer : KSerializer<UserWalletId> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UserWalletId", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UserWalletId) = encoder.encodeString(value.stringValue)
override fun deserialize(decoder: Decoder): UserWalletId = UserWalletId(decoder.decodeString())
}
/** Serializes [Network.RawID] as its bare [Network.RawID.value]. */
internal object NetworkRawIdAsStringSerializer : KSerializer<Network.RawID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Network.RawID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Network.RawID) = encoder.encodeString(value.value)
override fun deserialize(decoder: Decoder): Network.RawID = Network.RawID(decoder.decodeString())
}
/**
* Serializes [ContactName] as its bare [ContactName.value]. On read the string goes back through the
* validating [ContactName.invoke] gateway; an invalid name surfaces as a [SerializationException] (the
* cipher maps it to `MalformedBlob`).
*/
internal object ContactNameAsStringSerializer : KSerializer<ContactName> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ContactName", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ContactName) = encoder.encodeString(value.value)
override fun deserialize(decoder: Decoder): ContactName {
val raw = decoder.decodeString()
return ContactName(raw).getOrElse { error ->
throw SerializationException("Invalid contact name in address-book payload: $error")
}
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.addressbook.repository
import arrow.core.Either
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.wallet.UserWalletId
@ -8,15 +10,19 @@ 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 getContactsSync(userWalletId: UserWalletId): List<Contact>
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
/** Inserts or updates a [contact]. */
suspend fun saveContact(contact: Contact)
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>
suspend fun deleteContact(id: ContactId)
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>
suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit>
}

View file

@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact
* Builds the canonical bytes that are signed for a single [AddressEntry]:
* `address + networkId + memo + contactId + name`.
*
* Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and [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 {

View file

@ -0,0 +1,38 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
/**
* Enforces the `network + address` uniqueness rule within a wallet's address book: checks whether the
* ([networkId], [address]) pair is already saved and, if so, returns the name of the contact that holds it
* so the UI can tell the user under which name it is stored. Returns `null` when the pair is free.
*
* The same address in a different network is allowed. [excludeContactId] lets an in-place edit skip the
* contact currently being edited. Address comparison is exact (matching the in-editor dedup in
* `AddValidatedAddressTransformer`), so case-sensitive chains are not falsely flagged.
*
* Reads the local snapshot ([AddressBookRepository.getContactsSync]) rather than the syncing flow, so validating on
* every keystroke/selection change never triggers a backend sync.
*/
class CheckAddressDuplicateUseCase(
private val repository: AddressBookRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
networkId: String,
address: String,
excludeContactId: ContactId? = null,
): String? {
val contacts = repository.getContactsSync(userWalletId)
return contacts
.firstOrNull { contact ->
contact.id != excludeContactId && contact.addresses.any { entry ->
entry.networkId.value == networkId && entry.address == address
}
}
?.name?.value
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetContactByIdUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(id: ContactId): Flow<Contact?> =
repository.getAllContacts().map { contacts -> contacts.find { it.id == id } }
}

View file

@ -4,10 +4,34 @@ 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()
return source.map { contacts ->
val filtered = if (normalizedQuery.isEmpty()) {
contacts
} else {
contacts.filter { it.matches(normalizedQuery) }
}
filtered.sortedByDescending { it.createdAt }
}
}
private fun Contact.matches(query: String): Boolean {
val isNameContaining = name.value.contains(other = query, ignoreCase = true)
val isAddressContaining = addresses.any { addressEntry ->
addressEntry.address.contains(other = query, ignoreCase = false)
}
return isNameContaining || isAddressContaining
}
}

View file

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

View file

@ -0,0 +1,10 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.repository.AddressBookRepository
class SyncAddressBooksUseCase(
private val repository: AddressBookRepository,
) {
suspend operator fun invoke() = repository.syncAddressBooks()
}

View file

@ -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
}
}

View file

@ -1,37 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
/**
* Validates a contact's address for a network, reusing the transaction-layer validation. Self-send
* is allowed since saving one's own address in the book is valid.
*/
class ValidateContactAddressUseCase(
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
address: String,
): Either<AddressValidation.Error, Unit> = either {
val senderAddresses = getNetworkAddressesUseCase.invokeSync(
userWalletId = userWalletId,
networkRawId = network.id.rawId,
)
validateWalletAddressUseCase(
userWalletId = userWalletId,
network = network,
address = address,
senderAddresses = senderAddresses,
allowSelfSend = true,
).bind()
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -1,36 +1,45 @@
package com.tangem.domain.addressbook.usecase
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.AddressEntry
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
/**
* 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 ContactSignatureVerifier(
private val verifyMessages: VerifySecp256k1MessagesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
operator fun invoke(
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.addressEntries
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.
@ -40,7 +49,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] }

View file

@ -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()
@ -107,18 +127,6 @@ internal class AddressBookCipherTest {
assertThat(cipher.decrypt(second, wallet).rightValue()).isEqualTo(book)
}
@Test
fun `GIVEN book whose walletId differs from the wallet WHEN encrypt THEN WalletMismatch`() {
// Arrange
val book = addressBook().copy(walletId = UserWalletId("deadbeef"))
// Act
val result = cipher.encrypt(book, wallet, updatedAt)
// Assert
assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.WalletMismatch)
}
@Test
fun `GIVEN blob WHEN decrypt with a wallet of different id THEN WalletMismatch`() {
// Arrange
@ -192,7 +200,7 @@ internal class AddressBookCipherTest {
every { card } returns mockk { every { wallets } returns emptyList() }
}
}
val book = addressBook(walletId = wallet.walletId)
val book = addressBook()
// Act
val result = cipher.encrypt(book, lockedWallet, updatedAt)
@ -201,34 +209,55 @@ 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())
AddressBook(contacts = contacts.toList())
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(),
addresses = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
@ -241,6 +270,14 @@ internal class AddressBookCipherTest {
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 +309,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"
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.domain.addressbook.interactor
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.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.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetVerifiedContactsInteractorTest {
private val getContacts: GetContactsUseCase = mockk()
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
private val interactor = GetVerifiedContactsInteractor(
getContacts = getContacts,
contactSignatureVerifier = contactSignatureVerifier,
)
private val walletId = UserWalletId("011")
@BeforeEach
fun resetMocks() {
clearMocks(getContacts, contactSignatureVerifier)
}
@Test
fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest {
// Arrange
val contact = 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.getVerifiedContacts(query = "query", userWalletId = walletId).first()
// Assert
assertThat(result).containsExactly(verified)
coVerify(exactly = 1) { contactSignatureVerifier.verifyContacts(listOf(contact)) }
}
private fun contact(): 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 = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "AABB",
),
),
)
}

View file

@ -0,0 +1,336 @@
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.AddressBookSyncError
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.validation.ContactNameValidator
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
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.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 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 = contactNameValidator,
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, contactNameValidator, 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
stubValidName(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.right()
// 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.addresses.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
stubValidName(name = "Alice")
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.right()
// 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.addresses.map { it.signature })
.containsExactly(signatures[0].toHexString(), signatures[1].toHexString())
.inOrder()
}
@Test
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
// Arrange
stubValidName(name = "Alice")
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList())
// Assert
assertThat(result.getOrNull()).isEqualTo(saved.captured)
assertThat(saved.captured.addresses).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
}
stubValidName(name = "Alice")
// 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
stubValidName(name = "Alice")
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
coEvery { contactNameValidator.validate(userWallet.walletId, "alice") } returns
ContactNameValidationError.Duplicate.left()
// 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
coEvery { contactNameValidator.validate(userWallet.walletId, "") } returns
ContactNameValidationError.Format(ContactName.Error.Empty).left()
// 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()) }
}
@Test
fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest {
// Arrange
stubValidName(name = "Alice")
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
listOf(byteArrayOf(0x01)).right()
coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict))
}
private fun stubValidName(name: String) {
coEvery { contactNameValidator.validate(userWallet.walletId, name) } returns
requireNotNull(ContactName(name).getOrNull()).right()
}
}
@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.right()
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = existing,
name = "Bob",
iconColor = "TestColor",
addresses = 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.addresses.map { it.signature })
.containsExactly(signatures[0].toHexString())
coVerify(exactly = 0) { contactNameValidator.validate(any(), any()) }
}
@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",
addresses = 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",
addresses = 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,
addresses = 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",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
private companion object {
const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z"
const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z"
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.domain.addressbook.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import org.junit.jupiter.api.Test
/**
* Locks the JSON shape of the encrypted address-book payload a cross-platform (iOS) contract. Wallet id,
* contact name and network id must be bare strings, not `{"field": }` objects, so a Kotlin type change
* can't silently break interop.
*/
internal class AddressBookSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `GIVEN a contact WHEN serialized THEN wrapper types are plain strings`() {
// Arrange
val contact = Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("0a0a0a"),
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-05-22T09:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "",
),
),
)
// Act
val obj = json.parseToJsonElement(json.encodeToString(Contact.serializer(), contact)).jsonObject
// Assert
assertThat(obj["id"]).isEqualTo(JsonPrimitive("contact-1"))
assertThat(obj["walletId"]).isEqualTo(JsonPrimitive("0a0a0a"))
assertThat(obj["name"]).isEqualTo(JsonPrimitive("Alice"))
val entry = obj["addresses"]!!.jsonArray.single().jsonObject
assertThat(entry["id"]).isEqualTo(JsonPrimitive("addr-1"))
assertThat(entry["networkId"]).isEqualTo(JsonPrimitive("ethereum"))
}
@Test
fun `GIVEN serialized contact WHEN deserialized THEN original is restored`() {
// Arrange
val book = AddressBook(
contacts = listOf(
Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("0a0a0a"),
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-05-22T09:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = "memo",
signature = "sig",
),
),
),
),
)
// Act
val restored = json.decodeFromString(
AddressBook.serializer(),
json.encodeToString(AddressBook.serializer(), book),
)
// Assert
assertThat(restored).isEqualTo(book)
}
@Test
fun `GIVEN payload with an invalid contact name WHEN deserialized THEN fails`() {
// Arrange — empty name violates the ContactName rules
val payload = """{"contacts":[{"id":"c1","walletId":"0a0a0a","name":"",""" +
""""icon":"","iconColor":"c","createdAt":"t","updatedAt":"t","addresses":[]}]}"""
// Act
val error = runCatching { json.decodeFromString(AddressBook.serializer(), payload) }.exceptionOrNull()
// Assert
assertThat(error).isNotNull()
}
@Test
fun `GIVEN entry with null memo WHEN serialized THEN memo key is omitted`() {
// Arrange — kotlinx omits properties equal to their default, so a null memo must not appear in the JSON,
// matching iOS's encodeIfPresent for String? optionals.
val entry = AddressEntry(
id = AddressEntryId("a1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
)
// Act
val obj = json.parseToJsonElement(json.encodeToString(AddressEntry.serializer(), entry)).jsonObject
// Assert
assertThat(obj.keys).doesNotContain("memo")
}
}

View file

@ -39,8 +39,33 @@ class ContactNameTest {
}
@Test
fun `emoji is rejected`() {
assertThat(ContactName("Alice 😀").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
fun `simple emoji is accepted`() {
assertThat(ContactName("Alice 😀").isRight()).isTrue()
}
@Test
fun `emoji-only name is accepted`() {
assertThat(ContactName("😀").isRight()).isTrue()
}
@Test
fun `flag emoji is accepted`() {
assertThat(ContactName("Team 🇺🇸").isRight()).isTrue()
}
@Test
fun `zwj emoji sequence is accepted`() {
assertThat(ContactName("Family 👨‍👩‍👧").isRight()).isTrue()
}
@Test
fun `emoji with variation selector is accepted`() {
assertThat(ContactName("Love ❤️").isRight()).isTrue()
}
@Test
fun `non-latin letters are accepted`() {
assertThat(ContactName("Алёша 大阪").isRight()).isTrue()
}
@Test
@ -53,6 +78,16 @@ class ContactNameTest {
assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
}
@Test
fun `zero-width space is rejected`() {
assertThat(ContactName("Ali\u200Bce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
}
@Test
fun `non-breaking space is rejected`() {
assertThat(ContactName("Ali\u00A0ce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
}
@Test
fun `html script is rejected`() {
assertThat(ContactName("<script>alert(1)</script>").leftOrNull())

View file

@ -0,0 +1,101 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.*
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.coEvery
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 CheckAddressDuplicateUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = CheckAddressDuplicateUseCase(repository)
private val walletId = UserWalletId("0001")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN network and address already saved WHEN invoke THEN returns owning contact name`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA")
// Assert
assertThat(result).isEqualTo("Binance")
}
@Test
fun `GIVEN same address in a different network WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = TRON, address = "0xAAA")
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN the pair belongs to the excluded contact WHEN invoke THEN returns null`() = runTest {
// Arrange
val contact = contact("Binance", "0xAAA", ETHEREUM, id = "id-1")
coEvery { repository.getContactsSync(walletId) } returns listOf(contact)
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA", excludeContactId = ContactId("id-1"))
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN free pair WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xBBB")
// Assert
assertThat(result).isNull()
}
private fun contact(name: String, address: String, networkId: String, id: String = "id-$name"): Contact = Contact(
id = ContactId(id),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID(networkId),
memo = null,
signature = "sig",
),
),
)
private companion object {
const val ETHEREUM = "ethereum"
const val TRON = "tron"
}
}

View file

@ -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",
),
),
)
}

View file

@ -0,0 +1,65 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
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.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetContactByIdUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactByIdUseCase(repository)
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN matching id WHEN invoke THEN emits that contact`() = runTest {
// Arrange
val target = contact("id-2", "Bob")
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), target))
// Act
val result = useCase(ContactId("id-2")).first()
// Assert
assertThat(result).isEqualTo(target)
}
@Test
fun `GIVEN no matching id WHEN invoke THEN emits null`() = runTest {
// Arrange
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice")))
// Act
val result = useCase(ContactId("missing")).first()
// Assert
assertThat(result).isNull()
}
private fun contact(id: String, name: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("0001"),
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = emptyList(),
)
}

View file

@ -0,0 +1,160 @@
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 address query matching case WHEN invoke THEN returns matching contact`() = runTest {
// Arrange
val carol = contact(name = "Carol", address = "0xAbCdEf")
every { repository.getAllContacts() } returns flowOf(listOf(alice, carol))
// Act
val result = useCase(query = "0xAbCdEf").first()
// Assert
assertThat(result).containsExactly(carol)
}
@Test
fun `GIVEN address query with different case WHEN invoke THEN returns empty`() = runTest {
// Arrange
val carol = contact(name = "Carol", address = "0xAbCdEf")
every { repository.getAllContacts() } returns flowOf(listOf(alice, carol))
// Act
val result = useCase(query = "0xabcdef").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN name query with different case WHEN invoke THEN returns matching contact`() = runTest {
// Act
val result = useCase(query = "ALICE").first()
// Assert
assertThat(result).containsExactly(alice)
}
@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 contacts with different createdAt WHEN invoke THEN sorted newest first`() = runTest {
// Arrange
val older = contact(name = "Older", address = "0x1", createdAt = "2026-01-01T00:00:00.000Z")
val newer = contact(name = "Newer", address = "0x2", createdAt = "2026-06-01T00:00:00.000Z")
every { repository.getAllContacts() } returns flowOf(listOf(older, newer))
// Act
val result = useCase(query = "").first()
// Assert
assertThat(result).containsExactly(newer, older).inOrder()
}
@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,
createdAt: String = "2026-01-01T00:00:00.000Z",
): Contact = Contact(
id = ContactId("id-$name"),
walletId = UserWalletId("011"),
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = createdAt,
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
),
)
}

View file

@ -1,145 +0,0 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.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))
}
}

View file

@ -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",
),
),
)
}

View file

@ -1,75 +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.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
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 ValidateContactAddressUseCaseTest {
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase = mockk()
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk()
private val useCase = ValidateContactAddressUseCase(
validateWalletAddressUseCase = validateWalletAddressUseCase,
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
)
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 }
@BeforeEach
fun resetMocks() {
clearMocks(validateWalletAddressUseCase, getNetworkAddressesUseCase)
}
@Test
fun `valid address forwards sender addresses and allows self-send`() = runTest {
val senderAddresses = listOf<CryptoCurrencyAddress>(mockk())
coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns senderAddresses
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val result = useCase(walletId, network, "0xabc")
assertThat(result.isRight()).isTrue()
coVerify {
validateWalletAddressUseCase(
userWalletId = walletId,
network = network,
address = "0xabc",
senderAddresses = senderAddresses,
allowSelfSend = true,
)
}
}
@Test
fun `invalid address propagates the validation error`() = runTest {
coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns emptyList()
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Error.InvalidAddress.left()
val result = useCase(walletId, network, "bad")
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
}
}

View file

@ -1,79 +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()),
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
),
),
)
}

View file

@ -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
}

View file

@ -0,0 +1,108 @@
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",
),
),
)
}

View file

@ -0,0 +1,262 @@
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"),
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
}

View file

@ -1,19 +1,18 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
dependencies {
/** Project - Domain */
implementation(projects.core.utils)
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(deps.moshi.kotlin)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
// region Other libraries
api(deps.arrow.core)
api(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
}

View file

@ -5,8 +5,15 @@ plugins {
dependencies {
/** Project - Domain */
implementation(projects.core.utils)
implementation(projects.domain.core)
implementation(projects.domain.appCurrency.models)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain models
api(projects.domain.appCurrency.models)
// endregion
}

View file

@ -5,5 +5,5 @@ plugins {
}
dependencies {
implementation(deps.kotlin.serialization)
api(deps.kotlin.serialization)
}

View file

@ -5,8 +5,15 @@ plugins {
dependencies {
/** Project - Domain */
implementation(projects.core.utils)
implementation(projects.domain.core)
implementation(projects.domain.appTheme.models)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain models
api(projects.domain.appTheme.models)
// endregion
}

1
domain/app-update/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
implementation(projects.core.utils)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
// region Test
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}

View file

@ -0,0 +1,28 @@
package com.tangem.domain.appupdate.model
/**
* Result of checking whether the application needs an update.
*/
enum class AppUpdateState {
/** Update is mandatory — a blocking screen with an "Update now" button must be shown. */
ForceUpdate,
/**
* Update is mandatory but impossible on this device (OS too old for the critical version)
* a permanently blocking "brick" screen must be shown.
*/
Brick,
/**
* Update is mandatory but the device OS is too old for the min-supported version a blocking
* "update your OS" screen must be shown.
*/
OsTooOld,
/** Update is available but optional — a dismissible screen may be shown. */
OptionalUpdate,
/** No update is required. */
NoUpdate,
}

View file

@ -0,0 +1,37 @@
package com.tangem.domain.appupdate.model
internal class AppVersion private constructor(
private val major: Int,
private val minor: Int,
private val fix: Int,
) : Comparable<AppVersion> {
override fun compareTo(other: AppVersion): Int {
major.compareTo(other.major).let { if (it != 0) return it }
minor.compareTo(other.minor).let { if (it != 0) return it }
return fix.compareTo(other.fix)
}
companion object {
private const val DELIMITER = "."
private const val MAJOR = 0
private const val MINOR = 1
private const val FIX = 2
fun parseOrNull(value: String): AppVersion? {
// Drop build-type/pre-release suffixes ("6.1-internal", "1.0.0-SNAPSHOT") before parsing.
val parts = value.trim().substringBefore('-').substringBefore('+').split(DELIMITER)
val major = parts.getOrNull(MAJOR)?.toIntOrNull() ?: return null
val minor = parts.getOrNull(MINOR).toVersionPartOrNull() ?: return null
val fix = parts.getOrNull(FIX).toVersionPartOrNull() ?: return null
return AppVersion(major = major, minor = minor, fix = fix)
}
private fun String?.toVersionPartOrNull(): Int? = when (this) {
null -> 0
else -> toIntOrNull()
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.appupdate.model
/**
* Backend-driven update policy. All fields are nullable; a null threshold skips its check.
*
* @property minSupportedVersion mandatory-update threshold (inclusive): `installedVersion <= minSupportedVersion`
* @property minSupportedOSVersion OS threshold for the min-supported case (exclusive): `deviceOsVersion < it` -> OS too old
* @property criticalVersion critical-update threshold (inclusive): `installedVersion <= criticalVersion`
* @property criticalOSVersion OS threshold for the critical case (exclusive): `deviceOsVersion < it` -> brick
* @property latestVersion optional-update threshold (exclusive): `installedVersion < latestVersion`
*/
data class AppVersionInfo(
val minSupportedVersion: String?,
val minSupportedOSVersion: String?,
val criticalVersion: String?,
val criticalOSVersion: String?,
val latestVersion: String?,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.appupdate.model
data class OptionalUpdateShown(
val version: String,
val shownAtMillis: Long,
)

View file

@ -0,0 +1,21 @@
package com.tangem.domain.appupdate.repository
import arrow.core.Either
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
interface AppUpdateRepository {
/** Last cached version thresholds, or `null` if nothing has been fetched yet. No network. */
suspend fun getCachedAppVersionInfo(): AppVersionInfo?
/** Wall-clock time (millis) of the last successful fetch, or `null` if nothing has been fetched yet. */
suspend fun getCachedAppVersionTimestamp(): Long?
/** Fetches fresh thresholds and, on success, overwrites the cache (and its timestamp). */
suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo>
suspend fun getOptionalUpdateShown(): OptionalUpdateShown?
suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown)
}

View file

@ -0,0 +1,119 @@
package com.tangem.domain.appupdate.usecase
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.model.AppVersion
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
class GetAppUpdateStateUseCase(
private val repository: AppUpdateRepository,
private val appInfoProvider: AppInfoProvider,
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
) {
/**
* Instant decision computed from the cached thresholds and the current app/OS version. No network.
* Records the optional update as shown when it decides to show it (24h throttle). Never throws
* any failure resolves to [AppUpdateState.NoUpdate] so the initial navigation is never blocked.
*/
suspend fun getCached(): AppUpdateState = runSuspendCatching {
resolve(freshCachedInfoOrNull(), recordOptionalShown = true)
}.getOrElse { error ->
TangemLogger.e("Unable to resolve cached app update state", error)
AppUpdateState.NoUpdate
}
/**
* Fetches fresh thresholds (overwriting the cache) and re-evaluates. Falls back to the cache on a
* network error. Does not record the optional update used for background and on-screen refreshes.
* Never throws any failure resolves to [AppUpdateState.NoUpdate].
*/
suspend fun refresh(): AppUpdateState = runSuspendCatching {
val info = repository.refreshAppVersionInfo().getOrNull() ?: freshCachedInfoOrNull()
resolve(info, recordOptionalShown = false)
}.getOrElse { error ->
TangemLogger.e("Unable to resolve app update state", error)
AppUpdateState.NoUpdate
}
/**
* Cached thresholds, but only while they are still fresh. A cache older than [CACHE_TTL_MILLIS] is
* ignored so a permanently unreachable backend can't keep the user blocked forever a successful
* fetch is required at least once per TTL window to keep a blocking threshold in effect.
*/
private suspend fun freshCachedInfoOrNull(): AppVersionInfo? {
val cachedAt = repository.getCachedAppVersionTimestamp() ?: return null
if (currentTimeMillis() - cachedAt > CACHE_TTL_MILLIS) return null
return repository.getCachedAppVersionInfo()
}
private suspend fun resolve(info: AppVersionInfo?, recordOptionalShown: Boolean): AppUpdateState {
info ?: return AppUpdateState.NoUpdate
val appVersion = AppVersion.parseOrNull(appInfoProvider.appVersion) ?: return AppUpdateState.NoUpdate
val deviceOsVersion = AppVersion.parseOrNull(appInfoProvider.osVersion)
val latestVersion = info.latestVersion?.let(AppVersion::parseOrNull)
val criticalVersion = info.criticalVersion?.let(AppVersion::parseOrNull)
if (criticalVersion != null && appVersion <= criticalVersion && isEscapable(latestVersion, criticalVersion)) {
return blockingStateFor(info.criticalOSVersion, deviceOsVersion, AppUpdateState.Brick)
}
val minSupportedVersion = info.minSupportedVersion?.let(AppVersion::parseOrNull)
if (minSupportedVersion != null &&
appVersion <= minSupportedVersion &&
isEscapable(latestVersion, minSupportedVersion)
) {
return blockingStateFor(info.minSupportedOSVersion, deviceOsVersion, AppUpdateState.OsTooOld)
}
if (info.latestVersion != null && latestVersion != null && appVersion < latestVersion) {
return resolveOptionalUpdate(info.latestVersion, recordOptionalShown)
}
return AppUpdateState.NoUpdate
}
/**
* A blocking threshold is honored only if the advertised latest version is strictly above it i.e.
* updating actually clears the block. A threshold no installable version can satisfy is a backend
* misconfiguration and is ignored.
*/
private fun isEscapable(latestVersion: AppVersion?, threshold: AppVersion): Boolean =
latestVersion != null && latestVersion > threshold
private suspend fun resolveOptionalUpdate(latestVersion: String, recordOptionalShown: Boolean): AppUpdateState {
if (!recordOptionalShown) return AppUpdateState.OptionalUpdate
val shown = repository.getOptionalUpdateShown()
val isThrottled = shown != null &&
shown.version == latestVersion &&
currentTimeMillis() - shown.shownAtMillis < OPTIONAL_UPDATE_INTERVAL_MILLIS
if (isThrottled) return AppUpdateState.NoUpdate
repository.setOptionalUpdateShown(
OptionalUpdateShown(version = latestVersion, shownAtMillis = currentTimeMillis()),
)
return AppUpdateState.OptionalUpdate
}
private fun blockingStateFor(
requiredOsVersion: String?,
deviceOsVersion: AppVersion?,
osTooOldState: AppUpdateState,
): AppUpdateState {
val requiredOs = requiredOsVersion?.let(AppVersion::parseOrNull)
val cannotUpdate = requiredOs != null && deviceOsVersion != null && deviceOsVersion < requiredOs
return if (cannotUpdate) osTooOldState else AppUpdateState.ForceUpdate
}
private companion object {
const val OPTIONAL_UPDATE_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val CACHE_TTL_MILLIS = 24L * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,273 @@
package com.tangem.domain.appupdate
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.utils.info.AppInfoProvider
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class GetAppUpdateStateUseCaseTest {
private val repository = mockk<AppUpdateRepository>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val useCase = GetAppUpdateStateUseCase(repository, appInfoProvider, currentTimeMillis = { NOW })
private fun givenCached(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo?) {
every { appInfoProvider.appVersion } returns appVersion
every { appInfoProvider.osVersion } returns osVersion
coEvery { repository.getCachedAppVersionInfo() } returns info
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
coEvery { repository.getOptionalUpdateShown() } returns null
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
}
private fun givenRefresh(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo) {
every { appInfoProvider.appVersion } returns appVersion
every { appInfoProvider.osVersion } returns osVersion
coEvery { repository.refreshAppVersionInfo() } returns info.right()
coEvery { repository.getOptionalUpdateShown() } returns null
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
}
private fun info(
minSupportedVersion: String? = null,
minSupportedOSVersion: String? = null,
criticalVersion: String? = null,
criticalOSVersion: String? = null,
latestVersion: String? = null,
) = AppVersionInfo(
minSupportedVersion = minSupportedVersion,
minSupportedOSVersion = minSupportedOSVersion,
criticalVersion = criticalVersion,
criticalOSVersion = criticalOSVersion,
latestVersion = latestVersion,
)
@Test
fun `GIVEN app at critical version and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "14",
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN app at critical version and OS too old WHEN getCached THEN Brick`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "9",
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.Brick)
}
@Test
fun `GIVEN app at min supported and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "14",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN app at min supported and OS too old WHEN getCached THEN OsTooOld`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "9",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OsTooOld)
}
@Test
fun `GIVEN critical above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
givenCached(appVersion = "5.20", info = info(criticalVersion = "9.99", latestVersion = "5.41"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN min supported above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
givenCached(appVersion = "5.20", info = info(minSupportedVersion = "9.99", latestVersion = "5.41"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN blocking threshold but no latest WHEN getCached THEN ignored as NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", criticalOSVersion = "10"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN app below latest and not shown before WHEN getCached THEN OptionalUpdate is recorded`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
coVerify(exactly = 1) {
repository.setOptionalUpdateShown(OptionalUpdateShown(version = "5.37", shownAtMillis = NOW))
}
}
@Test
fun `GIVEN app at latest WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.37", info = info(latestVersion = "5.37"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN optional shown for same version within 24h WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS + 1)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
}
@Test
fun `GIVEN optional shown for same version over 24h ago WHEN getCached THEN OptionalUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS - 1)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN optional shown for older version WHEN getCached THEN OptionalUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.36", shownAtMillis = NOW)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN all thresholds null WHEN getCached THEN NoUpdate`() = runTest {
givenCached(info = info())
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN critical and latest both match WHEN getCached THEN critical wins`() = runTest {
givenCached(
appVersion = "3.0",
osVersion = "14",
info = info(criticalVersion = "3.0", minSupportedVersion = "3.0", latestVersion = "5.37"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN no cache WHEN getCached THEN NoUpdate`() = runTest {
givenCached(info = null)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN cache older than TTL WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN cache exactly at TTL WHEN getCached THEN still blocks`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN cache without timestamp WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns null
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN refresh fails and cache stale WHEN refresh THEN NoUpdate`() = runTest {
every { appInfoProvider.appVersion } returns "5.0"
every { appInfoProvider.osVersion } returns "14"
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN refresh returns blocking info WHEN refresh THEN ForceUpdate`() = runTest {
givenRefresh(appVersion = "5.0", osVersion = "14", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN refresh returns optional info WHEN refresh THEN OptionalUpdate without recording`() = runTest {
givenRefresh(appVersion = "5.0", info = info(latestVersion = "5.37"))
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.OptionalUpdate)
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
}
@Test
fun `GIVEN refresh fails WHEN refresh THEN falls back to cached thresholds`() = runTest {
every { appInfoProvider.appVersion } returns "5.0"
every { appInfoProvider.osVersion } returns "14"
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN repository throws WHEN getCached THEN NoUpdate`() = runTest {
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
coEvery { repository.getCachedAppVersionInfo() } throws IllegalStateException("boom")
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN repository throws WHEN refresh THEN NoUpdate`() = runTest {
coEvery { repository.refreshAppVersionInfo() } throws IllegalStateException("boom")
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
}
private companion object {
const val NOW = 1_000_000_000_000L
const val DAY_MILLIS = 24L * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.domain.appupdate.model
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class AppVersionTest {
@Test
fun `GIVEN single segment WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("14")).isNotNull()
}
@Test
fun `GIVEN major and minor WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("5.30")).isNotNull()
}
@Test
fun `GIVEN major minor fix WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("5.40.1")).isNotNull()
}
@Test
fun `GIVEN empty minor part WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5..1")).isNull()
}
@Test
fun `GIVEN non-numeric part WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5.x")).isNull()
}
@Test
fun `GIVEN blank WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("")).isNull()
}
@Test
fun `GIVEN trailing dot WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5.")).isNull()
}
@Test
fun `GIVEN older version WHEN compare THEN less than newer`() {
assertThat(AppVersion.parseOrNull("14")!! < AppVersion.parseOrNull("15.0")!!).isTrue()
}
@Test
fun `GIVEN fix difference WHEN compare THEN ordered`() {
assertThat(AppVersion.parseOrNull("5.40.1")!! > AppVersion.parseOrNull("5.40.0")!!).isTrue()
}
@Test
fun `GIVEN missing minor WHEN compare to explicit zero THEN equal`() {
val implicit = AppVersion.parseOrNull("14")!!
val explicit = AppVersion.parseOrNull("14.0")!!
assertThat(implicit.compareTo(explicit)).isEqualTo(0)
}
@Test
fun `GIVEN build-type suffix WHEN parse THEN parsed without suffix`() {
assertThat(AppVersion.parseOrNull("6.1-internal")).isNotNull()
}
@Test
fun `GIVEN snapshot fallback WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("1.0.0-SNAPSHOT")).isNotNull()
}
@Test
fun `GIVEN suffixed version WHEN compare to clean THEN equal`() {
val suffixed = AppVersion.parseOrNull("6.1-internal")!!
val clean = AppVersion.parseOrNull("6.1")!!
assertThat(suffixed.compareTo(clean)).isEqualTo(0)
}
}

View file

@ -6,8 +6,4 @@ plugins {
android {
namespace = "com.tangem.domain.appsflyer"
}
dependencies {
implementation(deps.kotlin.coroutines)
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.appsflyer
/** Known AppsFlyer navigational deep links, keyed by their `deep_link_value`. */
enum class AppsFlyerDeeplink(val deepLinkValue: String) {
TangemPayMobileOnboarding(deepLinkValue = "tpay_mobileonboard"),
Referral(deepLinkValue = "referral"),
;
companion object {
fun from(deepLinkValue: String?): AppsFlyerDeeplink? = entries.firstOrNull { it.deepLinkValue == deepLinkValue }
}
}

View file

@ -1,6 +0,0 @@
package com.tangem.domain.appsflyer
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
Referral,
}

View file

@ -1,10 +1,8 @@
package com.tangem.domain.appsflyer.repository
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository {
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
suspend fun getDeeplink(): String?
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
suspend fun clearDeeplink()
}

View file

@ -1,12 +1,11 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
class ClearAppsFlyerDeeplinkUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(source: AppsFlyerDeeplinkSource) {
appsFlyerRepository.clearDeeplink(source)
suspend operator fun invoke() {
appsFlyerRepository.clearDeeplink()
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
/**
@ -10,6 +10,6 @@ class IsReferralInstallUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(): Boolean {
return appsFlyerRepository.getDeeplink(AppsFlyerDeeplinkSource.Referral) != null
return AppsFlyerDeeplink.from(appsFlyerRepository.getDeeplink()) == AppsFlyerDeeplink.Referral
}
}

View file

@ -8,24 +8,29 @@ android {
namespace = "com.tangem.domain.assetsdiscovery"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
api(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.account.status)
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
// region Other libraries
api(deps.arrow.core)
api(tangemDeps.blockchain)
// endregion
// region Core modules
api(projects.core.analytics)
api(projects.core.utils)
implementation(projects.core.analytics.models)
// endregion
// region Domain
api(projects.domain.account.status)
api(projects.domain.models)
// endregion
// region Tests
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion

View file

@ -4,8 +4,16 @@ plugins {
}
dependencies {
implementation(projects.domain.core)
implementation(deps.kotlin.coroutines)
implementation(projects.domain.settings)
implementation(projects.domain.balanceHiding.models)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain models
api(projects.domain.balanceHiding.models)
// endregion
}

View file

@ -10,15 +10,17 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.core)
implementation(projects.domain.blockaid.models)
/** Tangem SDK */
implementation(tangemDeps.blockchain)
// region Other libraries
api(deps.arrow.core)
api(tangemDeps.blockchain)
// endregion
// region Domain
api(projects.domain.models)
// endregion
/** Other */
implementation(deps.moshi.adapters)
// region Domain models
api(projects.domain.blockaid.models)
// endregion
}

View file

@ -1,10 +1,4 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.ksp)
id("configuration")
}
dependencies {
/* Other */
implementation(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
}

View file

@ -8,30 +8,43 @@ android {
namespace = "com.tangem.domain.card"
}
dependencies {
implementation(projects.core.analytics.models)
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.domain.demo)
implementation(projects.domain.core)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager) // TODO refactor to use from data module
implementation(projects.libs.blockchainSdk)
// TODO: Remove after new card scan result was implemented
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
implementation(projects.core.utils)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
implementation(projects.libs.tangemSdkApi)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {
// region Other libraries
api(deps.arrow.core)
api(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
api(tangemDeps.card.core)
// endregion
/** Testing libraries */
testImplementation(projects.common.test)
// region Core modules
api(projects.core.analytics.models)
api(projects.core.utils)
// core:error provides UniversalError — a supertype of VisaActivationError (used via
// domain:visa:models) the compiler needs on the classpath, though never referenced directly.
implementation(projects.core.error)
// endregion
// region Domain
implementation(projects.domain.core)
implementation(projects.domain.demo.models)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.visa.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
api(projects.libs.tangemSdkApi)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -22,6 +22,7 @@ object VisaUtilities {
val visaDefaultDerivationPath
get() = visaBlockchain.derivationPath(DerivationStyle.V3)
val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0")
val virtualAccountDerivationPath = DerivationPath("m/44'/60'/999998'/0/0")
val curve = EllipticCurve.Secp256k1
fun signWithNonceMessage(nonce: String): String {

View file

@ -6,15 +6,11 @@ plugins {
dependencies {
api(deps.arrow.core)
api(deps.arrow.fx)
api(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
api(projects.core.analytics.models)
api(projects.domain.models)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.common.wallets
import com.tangem.domain.models.wallet.UserWalletId
/** Removes per-wallet data owned by a feature when its wallets are deleted. */
interface UserWalletDataCleaner {
suspend fun clear(userWalletIds: List<UserWalletId>)
}

View file

@ -6,10 +6,9 @@ plugins {
dependencies {
api(deps.arrow.core)
api(deps.arrow.fx)
api(deps.arrow.atomic)
api(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
api(deps.kotlin.serialization)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)

View file

@ -11,6 +11,6 @@ android {
dependencies {
api(projects.domain.demo.models)
implementation(tangemDeps.blockchain)
api(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
}

View file

@ -9,6 +9,5 @@ android {
}
dependencies {
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
api(tangemDeps.blockchain)
}

View file

@ -8,19 +8,36 @@ android {
namespace = "com.tangem.domain.dynamicaddresses"
}
dependencies {
api(projects.domain.core)
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
implementation(tangemDeps.blockchain) {
// region Other libraries
api(deps.arrow.core)
api(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
api(tangemDeps.card.core)
// endregion
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// region Domain
api(projects.domain.models)
api(projects.domain.walletManager)
api(projects.domain.wallets)
// endregion
// region Domain models
api(projects.domain.dynamicAddresses.models)
// endregion
// region Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region Tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -5,10 +5,26 @@ plugins {
}
dependencies {
api(projects.domain.core)
api(projects.domain.models)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Core modules
api(projects.core.pagination)
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(deps.kotlin.serialization)
// endregion
// region Domain
api(projects.domain.account)
api(projects.domain.common)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
}

View file

@ -5,6 +5,21 @@ plugins {
}
dependencies {
api(projects.domain.express.models)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain
api(projects.domain.core)
api(projects.domain.models)
// endregion
// region Domain models
api(projects.domain.express.models)
// endregion
}

View file

@ -1,11 +1,24 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.ksp)
id("configuration")
}
dependencies {
implementation(deps.moshi.adapters)
implementation(deps.kotlin.serialization)
implementation(projects.domain.tokens.models)
// region Kotlin
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.onramp.models)
api(projects.domain.tokens.models)
// endregion
}

View file

@ -1,5 +1,8 @@
package com.tangem.domain.express.models
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
/**
* An express exchange (swap) operation, independent of how it is presented in the transaction history.
*
@ -11,8 +14,17 @@ package com.tangem.domain.express.models
* @property provider The provider behind the deal; `null` if not resolved.
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
* @property fromAddress Address the `from` assets were taken from (the user's own source address).
* @property payoutAddress Address that received the `to` assets the user's own address for a regular swap, an
* external one for a send-and-swap.
* @property fromAsset The asset sent.
* @property toAsset The asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (CEX only).
* @property payinAddress Provider deposit address the pay-in was sent to the per-deal discriminator for the
* heuristic on-chain match of the outgoing (pay-in) leg.
* @property updatedAtMillis Last status-update timestamp (ms since epoch). Bounds the refund heuristic time
*/
data class ExchangeTransaction(
val txId: String,
@ -21,6 +33,17 @@ data class ExchangeTransaction(
val provider: ExpressProvider?,
val payinHash: String?,
val payoutHash: String?,
val fromAddress: String,
val payoutAddress: String,
val fromAsset: ExpressTransactionAsset,
val toAsset: ExpressTransactionAsset,
val externalTxUrl: String?,
val payinAddress: String,
val updatedAtMillis: Long,
val refundAssetId: ExpressAsset.ID?,
val refundCurrency: CryptoCurrency?,
val fromAmount: BigDecimal,
val toAmount: BigDecimal,
val toActualAmount: BigDecimal?,
)

View file

@ -1,5 +1,6 @@
package com.tangem.domain.express.models
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.serialization.Serializable
/**
@ -39,6 +40,13 @@ data class ExpressAsset(
operator fun invoke(networkId: String, contractAddress: String?): ID {
return ID(networkId = networkId, contractAddress = contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE)
}
operator fun invoke(cryptoCurrency: CryptoCurrency): ID {
return ID(
networkId = cryptoCurrency.network.rawId,
contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress,
)
}
}
}

View file

@ -53,6 +53,28 @@ enum class ExpressExchangeStatus(val raw: String) {
-> false
}
val isFinished: Boolean
get() = when (this) {
Finished,
-> true
Preview,
Expired,
Unknown,
Refunded,
TxFailed,
Paused,
Created,
ExchangeTxSent,
Waiting,
WaitingTxHash,
Confirming,
Exchanging,
Sending,
Failed,
Verifying,
-> false
}
companion object {
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
}

View file

@ -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,
)

View file

@ -1,7 +1,9 @@
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
import java.math.BigDecimal
/**
* An express onramp operation, independent of how it is presented in the transaction history.
@ -13,8 +15,12 @@ import com.tangem.domain.tokens.model.AmountType
* @property provider The provider behind the deal; `null` if not resolved.
* @property payoutHash On-chain hash of the payout (received) leg, if known.
* @property payoutAddress Address that received the crypto (the user's own address).
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (not provided by all providers).
* @property country The country the onramp was made from; `null` if not resolved.
*/
data class OnrampTransaction(
val txId: String,
@ -22,7 +28,12 @@ data class OnrampTransaction(
val createdAtMillis: Long,
val provider: ExpressProvider?,
val payoutHash: String?,
val payoutAddress: String,
/** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount,
val toAsset: ExpressTransactionAsset,
val country: OnrampCountry?,
val externalTxUrl: String?,
val toAmount: BigDecimal?,
val toActualAmount: BigDecimal?,
)

View file

@ -35,4 +35,6 @@ interface ExpressServiceFetcher {
* @return A flow emitting Lce states containing either a list of Express assets or an error.
*/
fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<ExpressAsset>>>
suspend fun getOrFetch(userWalletId: UserWalletId, assetId: ExpressAsset.ID): Either<Throwable, ExpressAsset>
}

View file

@ -9,15 +9,28 @@ android {
}
dependencies {
implementation(deps.arrow.core)
// region Other libraries
api(deps.arrow.core)
implementation(deps.jodatime)
// endregion
// region Core modules
implementation(projects.core.res)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
implementation(projects.domain.feedback.models)
// endregion
/** Testing libraries */
testImplementation(projects.test.core)
// region Domain
api(projects.domain.models)
implementation(projects.domain.visa.models)
// endregion
// region Domain models
api(projects.domain.feedback.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
// endregion
}

View file

@ -1,15 +1,17 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.ksp)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
dependencies {
/* Other */
implementation(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
implementation(deps.kotlin.serialization)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
// region Kotlin
api(deps.kotlin.serialization)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.visa.models)
// endregion
}

View file

@ -9,16 +9,23 @@ android {
}
dependencies {
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
testImplementation(deps.test.junit5)
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -1,48 +1,41 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.domain.features"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.error)
implementation(projects.common)
implementation(projects.libs.auth)
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.core)
implementation(projects.domain.demo)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android) {
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
api(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
// endregion
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
// region Domain
api(projects.domain.core)
// endregion
/** Testing libraries */
// region Domain models
api(projects.domain.models)
api(projects.domain.tokens.models)
api(projects.domain.transaction.models)
// endregion
// region Tests
testImplementation(deps.moshi)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.libs.blockchainSdk)
// endregion
}

View file

@ -251,5 +251,15 @@
"info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction"
},
"0x4b072692": {
"info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction"
},
"0xf9b181bf": {
"info": "GaslessTransactions",
"source": "https://github.com/tangem-developments/tangem-gasless-service",
"name": "gaslessTransaction"
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.domain
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import java.io.File
/**
* Guards the `contract_methods.json` asset consumed by `SdkTransactionTypeConverter` (via
* `DefaultWalletManagersFacade.readSmartContractMethods`). History marking of gasless fee transfers
* relies on every gasless entry-point selector being mapped to the `gaslessTransaction` method name.
*/
internal class ContractMethodsAssetTest {
private val methods: Map<String, Map<String, String>> by lazy {
val json = File("src/main/assets/contract_methods.json").readText()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java),
)
requireNotNull(Moshi.Builder().build().adapter<Map<String, Map<String, String>>>(type).fromJson(json))
}
@ParameterizedTest
@ValueSource(strings = ["0x6234d42b", "0x4b072692", "0xf9b181bf"])
fun `GIVEN gasless selector WHEN asset parsed THEN maps to gaslessTransaction`(selector: String) {
val entry = methods[selector]
assertThat(entry).isNotNull()
assertThat(entry?.get("name")).isEqualTo("gaslessTransaction")
}
}

View file

@ -10,28 +10,37 @@ android {
dependencies {
/* Domain */
api(projects.domain.core)
api(projects.domain.manageTokens.models)
api(projects.domain.networks)
api(projects.domain.quotes)
api(projects.domain.walletManager)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.staking)
implementation(projects.domain.tokens)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.legacy)
// region Kotlin
implementation(deps.kotlin.coroutines)
runtimeOnly(deps.kotlin.coroutines.android)
// endregion
implementation(tangemDeps.blockchain)
// region Other libraries
api(deps.arrow.core)
// endregion
/* Core */
// region Core modules
api(projects.core.pagination)
testImplementation(projects.core.pagination)
api(projects.core.utils)
// endregion
/* Tests */
testImplementation(deps.test.junit5)
// region Domain
api(projects.domain.models)
// endregion
// region Domain models
api(projects.domain.manageTokens.models)
// endregion
// region Runtime
// room/coroutines-android reach the runtime classpath through transitive consumers that no longer
// arrive via a compile dependency — declare them runtimeOnly so the runtime graph stays complete.
runtimeOnly(deps.room.runtime)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -6,10 +6,11 @@ plugins {
dependencies {
/* Domain */
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
// region Kotlin
api(deps.kotlin.serialization)
// endregion
/** Other */
implementation(deps.kotlin.serialization)
// region Domain models
api(projects.domain.models)
// endregion
}

View file

@ -0,0 +1,37 @@
package com.tangem.domain.managetokens
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.runSuspendCatching
/**
* Checks whether the elliptic curve used by [networkId] for the [userWalletId] wallet supports the selected
* [derivationPath]. Used before adding a custom token to prevent adding a token with a derivation path that the
* network's curve cannot derive (e.g. an Algorand token with an EVM derivation path).
*
* Returns [Either.Right] with `true` when the derivation is supported, `false` otherwise. [Either.Left] is returned
* when the check itself fails unexpectedly.
*/
class CheckDerivationPathSupportedUseCase(
private val customTokensRepository: CustomTokensRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Either<Throwable, Boolean> = runSuspendCatching {
customTokensRepository.isDerivationPathSupported(
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
)
}.fold(
onSuccess = { it.right() },
onFailure = { it.left() },
)
}

View file

@ -43,5 +43,11 @@ interface CustomTokensRepository {
suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network>
suspend fun isDerivationPathSupported(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Boolean
fun createDerivationPath(rawPath: String): Network.DerivationPath
}

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
implementation(projects.domain.marketing.models)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,8 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
testImplementation(projects.test.core)
}

Some files were not shown because too many files have changed in this diff Show more