Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 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,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

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

@ -26,7 +26,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].
@ -41,8 +41,6 @@ class AddressBookCipher {
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,7 +53,7 @@ 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(),

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,15 @@
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,65 @@
package com.tangem.domain.addressbook.interactor
import arrow.core.Either
import arrow.core.right
import com.tangem.domain.addressbook.model.AddressEntriesVerification
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.hexToBytesOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetVerifiedContactsInteractor(
private val getContacts: GetContactsUseCase,
private val verifyMessages: VerifySecp256k1MessagesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
return getContacts(query, userWalletId).map { contacts ->
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
contacts.mapNotNull { contact ->
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
VerifiedContact(
contact = contact.copy(addresses = verification.valid),
invalidEntries = verification.invalid,
)
}
}
}
private fun verify(
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
val entries = contact.addresses
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
val wellFormed = entries.mapNotNull { entry ->
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
}
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
val signatures = wellFormed.map { (_, signature) -> signature }
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
.map { flags ->
val validIds = wellFormed
.filterIndexed { index, _ -> flags[index] }
.mapTo(HashSet()) { (entry, _) -> entry.id }
AddressEntriesVerification(
valid = entries.filter { it.id in validIds },
invalid = entries.filterNot { it.id in validIds },
)
}
}
}

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.ValidateContactNameUseCase
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
import com.tangem.utils.extensions.toHexString
import java.security.MessageDigest
import java.util.UUID
class SaveContactInteractor(
private val repository: AddressBookRepository,
private val validateContactName: ValidateContactNameUseCase,
private val signUseCase: SignUseCase,
private val timestampProvider: IsoTimestampProvider,
) {
suspend fun createContact(
userWallet: UserWallet,
name: String,
iconColor: String,
addresses: List<AddressEntry>,
): Either<SaveContactError, Contact> = either {
val userWalletId = userWallet.walletId
val validName = validateContactName(userWalletId, name)
.mapLeft(SaveContactError::Name)
.bind()
val now = timestampProvider.now()
val contact = Contact(
id = ContactId(UUID.randomUUID().toString()),
walletId = userWalletId,
name = validName,
icon = "",
iconColor = iconColor,
createdAt = now,
updatedAt = now,
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,24 @@
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,
@SerialName("networkName")
val networkName: String,
@SerialName("memo")
val memo: String?,
@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
/**
@ -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 = true)
}
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,55 +0,0 @@
package com.tangem.domain.addressbook.usecase
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.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,
) {
operator fun invoke(
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
val entries = contact.addressEntries
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
val wellFormed = entries.mapNotNull { entry ->
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
}
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
val signatures = wellFormed.map { (_, signature) -> signature }
return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures)
.map { flags ->
val validIds = wellFormed
.filterIndexed { index, _ -> flags[index] }
.mapTo(HashSet()) { (entry, _) -> entry.id }
AddressEntriesVerification(
valid = entries.filter { it.id in validIds },
invalid = entries.filterNot { it.id in validIds },
)
}
}
}

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(
@ -237,10 +266,19 @@ internal class AddressBookCipherTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
networkName = "Ethereum",
)
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
/** Raw AES-256-GCM open, mirroring [AddressBookCipher]'s transformation and tag size. */
private fun aesGcmOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, authTag: ByteArray): ByteArray =
Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
}.doFinal(ciphertext + authTag)
private fun <T> Either<AddressBookCryptoError, T>.rightValue(): T =
getOrNull() ?: error("Expected Either.Right but was $this")
@ -272,5 +310,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,209 @@
package com.tangem.domain.addressbook.interactor
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetVerifiedContactsInteractorTest {
private val getContacts: GetContactsUseCase = mockk()
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val interactor = GetVerifiedContactsInteractor(
getContacts = getContacts,
verifyMessages = verifyMessages,
userWalletsListRepository = userWalletsListRepository,
)
private val walletId = UserWalletId("011")
private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsInteractorTest.walletId }
@BeforeEach
fun resetMocks() {
clearMocks(getContacts, verifyMessages, userWalletsListRepository)
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
}
@Test
fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest {
// Arrange
val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB")
val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD")
val contact = contact(valid, invalid)
stubContacts(contact)
every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right()
// Act
val result = interactor(query = "").first()
// Assert
assertThat(result).containsExactly(
VerifiedContact(
contact = contact.copy(addresses = listOf(valid)),
invalidEntries = listOf(invalid),
),
)
}
@Test
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest {
// Arrange
val contact = contact(
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
)
stubContacts(contact)
val messagesSlot = slot<List<ByteArray>>()
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
} returns listOf(true, true).right()
// Act
interactor(query = "").first()
// Assert
assertThat(messagesSlot.captured.map { String(it) })
.containsExactly(
expectedPayload(contact, contact.addresses[0]),
expectedPayload(contact, contact.addresses[1]),
)
.inOrder()
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
}
@Test
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest {
// Arrange
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
val contact = contact(valid1, invalid, valid2)
stubContacts(contact)
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
// Act
val result = interactor(query = "").first().single()
// Assert
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder()
assertThat(result.invalidEntries).containsExactly(invalid)
}
@Test
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest {
// Arrange
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
val contact = contact(malformed, signed)
stubContacts(contact)
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
} returns listOf(true).right()
// Act
val result = interactor(query = "").first().single()
// Assert
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
assertThat(result.contact.addresses).containsExactly(signed)
assertThat(result.invalidEntries).containsExactly(malformed)
}
@Test
fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest {
// Arrange
val contact = contact()
stubContacts(contact)
// Act
val result = interactor(query = "").first().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 invoke THEN contact is dropped`() = runTest {
// Arrange
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
// Act
val result = interactor(query = "").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest {
// Arrange
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
// Act
val result = interactor(query = "").first()
// Assert
assertThat(result).isEmpty()
}
private fun stubContacts(vararg contacts: Contact) {
every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList())
}
private fun contact(vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-1"),
walletId = walletId,
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = memo,
signature = signature,
)
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
}

View file

@ -0,0 +1,335 @@
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.usecase.ValidateContactNameUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SaveContactInteractorTest {
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
private val signUseCase: SignUseCase = mockk()
private val timestampProvider: IsoTimestampProvider = mockk {
every { now() } returns NEW_TIMESTAMP
}
private val interactor = SaveContactInteractor(
repository = repository,
validateContactName = ValidateContactNameUseCase(repository),
signUseCase = signUseCase,
timestampProvider = timestampProvider,
)
// MockUserWalletFactory builds each wallet key with publicKey = curve.name bytes → secp256k1 key is "Secp256k1"
private val userWallet: UserWallet = MockUserWalletFactory.create()
private val secp256k1Key = "Secp256k1".toByteArray()
private val networkRawId = Network.RawID("ethereum")
@BeforeEach
fun resetMocks() {
clearMocks(repository, signUseCase, answers = false)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CreateContact {
private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo"))
@Test
fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest {
// Arrange
stubNoExistingContacts()
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.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
stubNoExistingContacts()
val twoEntries = listOf(
entry(id = "addr-1", address = "0xabc", memo = "memo"),
entry(id = "addr-2", address = "0xdef", memo = null),
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>()
val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.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
stubNoExistingContacts()
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
}
stubNoExistingContacts()
// Act
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled")))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest {
// Arrange
every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice")))
// Act
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
// Act
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest {
// Arrange
stubNoExistingContacts()
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 stubNoExistingContacts() {
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class UpdateContact {
private val updatedEntries = listOf(entry(id = "addr-new", address = "0xnew", memo = "memo"))
@Test
fun `GIVEN existing contact WHEN updateContact THEN preserves id AND restamps AND persists without uniqueness check`() =
runTest {
// Arrange
val existing = contact(name = "Alice")
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.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) { repository.getContacts(any<UserWalletId>()) }
}
@Test
fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.NoSigningKey.left()
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "Bob",
iconColor = "TestColor",
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",
networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
private companion object {
const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z"
const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z"
}
}

View file

@ -0,0 +1,105 @@
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"),
networkName = "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"),
networkName = "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()
}
}

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,102 @@
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",
networkName = "Ethereum",
),
),
)
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,126 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetContactsUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactsUseCase(repository)
private val alice = contact(name = "Alice", address = "0xaaa")
private val bob = contact(name = "Bob", address = "0xbbb")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
every { repository.getAllContacts() } returns flowOf(listOf(alice, bob))
}
@Test
fun `GIVEN query matches a name WHEN invoke THEN returns only matching contacts`() = runTest {
// Act
val result = useCase(query = "ali").first()
// Assert
assertThat(result).containsExactly(alice)
}
@Test
fun `GIVEN query matches an address WHEN invoke THEN returns only matching contacts`() = runTest {
// Act
val result = useCase(query = "0xbbb").first()
// Assert
assertThat(result).containsExactly(bob)
}
@Test
fun `GIVEN blank query WHEN invoke THEN returns all contacts unfiltered`() = runTest {
// Act
val result = useCase(query = " ").first()
// Assert
assertThat(result).containsExactly(alice, bob)
}
@Test
fun `GIVEN query matches nothing WHEN invoke THEN returns empty list`() = runTest {
// Act
val result = useCase(query = "charlie").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN 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",
networkName = "Ethereum",
),
),
)
}

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

@ -64,15 +64,18 @@ class ValidateContactNameUseCaseTest {
id = ContactId("id-$name"),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)

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

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

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

@ -11,8 +11,14 @@ 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); `null` when unknown
* (very old app versions did not send it).
* @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; `null` when unknown.
* @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).
*/
data class ExchangeTransaction(
val txId: String,
@ -21,6 +27,9 @@ 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? = null,
)

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,5 +1,6 @@
package com.tangem.domain.express.models
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
@ -13,8 +14,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); `null` when unknown.
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
* @property country The country the onramp was made from; `null` if not resolved.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (not provided by all providers).
*/
data class OnrampTransaction(
val txId: String,
@ -22,7 +27,10 @@ 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? = null,
val externalTxUrl: String? = null,
)

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

@ -11,34 +11,34 @@ android {
dependencies {
/* Domain */
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
implementation(tangemDeps.blockchain)
// endregion
// region Core modules
api(projects.core.pagination)
implementation(projects.core.utils)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.quotes)
implementation(projects.domain.card)
// endregion
// region Domain models
api(projects.domain.appCurrency.models)
api(projects.domain.card)
api(projects.domain.core)
api(projects.domain.legacy)
api(projects.domain.markets.models)
api(projects.domain.models)
api(projects.domain.networks)
api(projects.domain.staking)
api(projects.domain.quotes)
api(projects.domain.walletManager)
api(projects.domain.wallets)
api(projects.domain.wallets.models)
api(projects.domain.stories)
// endregion
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.settings)
api(projects.core.pagination)
/* Libs */
// region Libs
api(projects.libs.blockchainSdk)
/* SDK */
implementation(tangemDeps.blockchain)
/* Utils */
implementation(deps.kotlin.serialization)
implementation(projects.core.utils)
// endregion
}

View file

@ -5,10 +5,16 @@ plugins {
}
dependencies {
api(projects.domain.models)
api(projects.domain.tokens.models)
implementation(projects.domain.core)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)
// region Kotlin
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.jodatime)
// endregion
// region Domain models
api(projects.domain.models)
// endregion
}

View file

@ -5,18 +5,37 @@ plugins {
id("configuration")
}
dependencies {
api(projects.domain.core)
api(projects.core.utils)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
// region Kotlin
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
// endregion
// region Other libraries
api(deps.arrow.core)
api(deps.jodatime)
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.kotlin.datetime)
implementation(deps.jodatime)
implementation(deps.kotlin.serialization)
ksp(deps.moshi.kotlin.codegen)
implementation(deps.arrow.core)
// endregion
// region Tangem SDK
api(tangemDeps.card.core)
api(tangemDeps.hot.core)
// endregion
// region Core modules
// core:utils is intentionally re-exported (api): domain:models is a ubiquitous dependency and many
// consumers rely on TangemLogger / utils through it. Demoting to implementation cascades across the
// whole repo, so keep it api despite DAGP's incorrect-configuration advice (suppressed below).
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.core)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.models.account
import kotlinx.serialization.Serializable
/**
* Bank (fiat) credentials for a Virtual Account on-ramp the wire/ACH requisites a user transfers funds to.
*
* Returned by `bff-v2/v1/account/bank-credentials/{product_instance_id}`. Sensitive data kept transient
* (never persisted in the local payment-account cache).
*/
@Serializable
data class BankCredentials(
val type: String,
val beneficiaryName: String,
val beneficiaryAddress: String,
val beneficiaryBankName: String,
val beneficiaryBankAddress: String,
val accountNumber: String,
val routingNumber: String,
)

View file

@ -149,6 +149,11 @@ sealed class PaymentAccountStatusValue {
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
* @property error Transient error overlaid on top of cached data when a refresh fails
* (see [copySealed]), or `null` when the status is up to date. Not persisted.
* @property virtualAccount Virtual Account (Visa on-ramp) availability VA MVP0 (TWI-1638), or `null`
* when not applicable (feature toggle off / wallet not eligible).
* Transient: not persisted in the local cache.
* @property tariffPlan Current tariff plan with subscription data (Tiers).
* Transient: not persisted in the local cache.
*/
@Serializable
data class Loaded(
@ -160,6 +165,8 @@ sealed class PaymentAccountStatusValue {
val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?,
val error: Error?,
val virtualAccount: VirtualAccountOnramp?,
val tariffPlan: TangemPayCustomerTariffPlan?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,

View file

@ -0,0 +1,50 @@
package com.tangem.domain.models.account
import com.tangem.domain.models.serialization.SerializedDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
/**
* Customer's current tariff plan.
*
* @property status Lifecycle status of the subscription.
* @property plan The currently active plan ([TangemPayTariffPlan]).
* @property nextBillingAt When the next plan fee is charged; `null` for free plans.
* @property pendingPlan Plan the customer will be moved to (scheduled downgrade), or `null`.
* @property pendingTransitionAt When [pendingPlan] is applied, or `null`.
*/
@Serializable
data class TangemPayCustomerTariffPlan(
@SerialName("status") val status: Status,
@SerialName("plan") val plan: TangemPayTariffPlan,
@SerialName("next_billing_at") val nextBillingAt: SerializedDateTime?,
@SerialName("pending_plan") val pendingPlan: TangemPayTariffPlan?,
@SerialName("pending_transition_at") val pendingTransitionAt: SerializedDateTime?,
) {
@Serializable
enum class Status {
@SerialName("ACTIVE")
ACTIVE,
@SerialName("TRANSITIONING")
TRANSITIONING,
@SerialName("CANCELED")
CANCELED,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"ACTIVE" -> ACTIVE
"TRANSITIONING" -> TRANSITIONING
"CANCELED" -> CANCELED
else -> UNKNOWN
}
}
}
}

View file

@ -0,0 +1,99 @@
package com.tangem.domain.models.account
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
data class TangemPayTariffPlan(
@SerialName("id") val id: String,
@SerialName("type") val type: Type,
@SerialName("name") val name: String,
@SerialName("description_items") val descriptionItems: List<DescriptionItem>,
@SerialName("images") val images: List<Image> = emptyList(),
) {
@Serializable
data class DescriptionItem(
@SerialName("section") val section: Section,
@SerialName("order") val order: Int,
@SerialName("title") val title: String,
@SerialName("body") val body: String,
)
@Serializable
data class Image(
@SerialName("type") val type: Type,
@SerialName("url") val url: String,
) {
@Serializable
enum class Type {
@SerialName("THUMBNAIL")
THUMBNAIL,
@SerialName("MAIN")
MAIN,
@SerialName("BANNER")
BANNER,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"THUMBNAIL" -> THUMBNAIL
"MAIN" -> MAIN
"BANNER" -> BANNER
else -> UNKNOWN
}
}
}
}
@Serializable
enum class Type {
@SerialName("BASIC")
BASIC,
@SerialName("PLUS")
PLUS,
@SerialName("PLUS_FF")
PLUS_FF,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"BASIC" -> BASIC
"PLUS" -> PLUS
"PLUS_FF" -> PLUS_FF
else -> UNKNOWN
}
}
}
@Serializable
enum class Section {
@SerialName("CARD_RELATED")
CARD_RELATED,
@SerialName("PLAN_RELATED")
PLAN_RELATED,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"CARD_RELATED" -> CARD_RELATED
"PLAN_RELATED" -> PLAN_RELATED
else -> UNKNOWN
}
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.models.account
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
data class TangemPayTariffPlanTransition(
@SerialName("type") val type: Type,
@SerialName("tariff_plan") val plan: TangemPayTariffPlan,
) {
@Serializable
enum class Type {
@SerialName("UPGRADE")
UPGRADE,
@SerialName("DOWNGRADE")
DOWNGRADE,
@SerialName("SYSTEM_DOWNGRADE")
SYSTEM_DOWNGRADE,
@SerialName("ACTIVATION")
ACTIVATION,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"UPGRADE" -> UPGRADE
"DOWNGRADE" -> DOWNGRADE
"SYSTEM_DOWNGRADE" -> SYSTEM_DOWNGRADE
"ACTIVATION" -> ACTIVATION
else -> UNKNOWN
}
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.models.account
import kotlinx.serialization.Serializable
/**
* Virtual Account (Visa on-ramp) availability for a payment account VA MVP0 (TWI-1638).
*
* Computed in the payment-account fetcher and surfaced on [PaymentAccountStatusValue.Loaded].
* Transient: [Available.bankCredentials] is never persisted in the local cache.
*/
@Serializable
sealed interface VirtualAccountOnramp {
/** No VA product instance yet, but the wallet is eligible to add funds (channel `VISA_VIRTUAL_ACCOUNT`). */
@Serializable
data object Eligible : VirtualAccountOnramp
/** VA product instance exists; [bankCredentials] are the fiat requisites for the bank-transfer top-up. */
@Serializable
data class Available(
val productInstanceId: String,
val bankCredentials: BankCredentials,
) : VirtualAccountOnramp
}

View file

@ -0,0 +1,50 @@
package com.tangem.domain.models.network
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Domain mirror of the blockchain SDK `Amount`, kept [Serializable] so it can be carried inside the serializable
* [TxInfo] graph (the SDK `Amount` is not serializable and pulls in blockchain-specific types).
*
* Holds a monetary value together with the metadata needed to display it. Compared to the SDK model it drops
* `maxValue` (irrelevant outside of "send" flows) and keeps only the currency identity on [SdkAmountType].
*
* @property currencySymbol display symbol of the currency (e.g. `ETH`, `USDT`)
* @property value amount value; `null` when the value is unknown
* @property decimals number of decimals of the currency
* @property type kind of currency the amount is denominated in
*/
@Serializable
data class SdkAmount(
val currencySymbol: String,
val value: SerializedBigDecimal? = null,
val decimals: Int,
val type: SdkAmountType = SdkAmountType.Coin,
)
/** Kind of currency an [SdkAmount] is denominated in. Mirrors the SDK `AmountType`. */
@Serializable
sealed interface SdkAmountType {
/** Native coin of the blockchain. */
@Serializable
data object Coin : SdkAmountType
/** Native coin used as a reserve currency for fee calculation (e.g. Algorand). */
@Serializable
data object Reserve : SdkAmountType
/** A resource that can be spent to pay the fee (e.g. Mana on Koinos). */
@Serializable
data class FeeResource(val name: String? = null) : SdkAmountType
/**
* A token of the blockchain.
*
* @property contractAddress token contract address
* @property id backend currency id, when known
*/
@Serializable
data class Token(val contractAddress: String, val id: String? = null) : SdkAmountType
}

View file

@ -15,6 +15,7 @@ import kotlinx.serialization.Serializable
* @property status transaction status
* @property type transaction type
* @property amount transaction amount
* @property fee transaction fee
*/
@Serializable
data class TxInfo(
@ -27,6 +28,7 @@ data class TxInfo(
val status: TransactionStatus,
val type: TransactionType,
val amount: SerializedBigDecimal,
val fee: SdkAmount? = null,
) {
/** Destination type*/

View file

@ -4,14 +4,45 @@ enum class TangemPayEligibilityType {
BANNER,
DETAILS,
DEEPLINK,
BANNER_VIRTUAL_ACCOUNT,
DETAILS_VIRTUAL_ACCOUNT,
DEEPLINK_VIRTUAL_ACCOUNT,
VISA_VIRTUAL_ACCOUNT,
UNKNOWN,
;
companion object {
fun fromString(value: String): TangemPayEligibilityType = when (value.lowercase()) {
"banner" -> BANNER
"details" -> DETAILS
fun fromString(value: String): TangemPayEligibilityType = when (value.uppercase()) {
"BANNER" -> BANNER
"DETAILS" -> DETAILS
"DEEPLINK" -> DEEPLINK
"BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT
"DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT
"DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT
"VISA_VIRTUAL_ACCOUNT" -> VISA_VIRTUAL_ACCOUNT
else -> UNKNOWN
}
}
}
}
val TangemPayEligibilityType.isVirtualAccountType: Boolean
get() = this in VIRTUAL_ACCOUNT_TYPES
val TangemPayEligibilityType.isTangemPayType: Boolean
get() = this in TANGEM_PAY_TYPES
private val VIRTUAL_ACCOUNT_TYPES = setOf(
TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT,
TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT,
TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT,
)
private val TANGEM_PAY_TYPES = setOf(
TangemPayEligibilityType.BANNER,
TangemPayEligibilityType.DETAILS,
TangemPayEligibilityType.DEEPLINK,
)

View file

@ -1,5 +1,6 @@
package com.tangem.domain.models.wallet
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.models.MobileWallet
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
@ -118,4 +119,10 @@ val UserWallet.isLocked
}
inline val UserWallet.isHotWallet get() = this is UserWallet.Hot
inline val UserWallet.isColdWallet get() = this is UserWallet.Cold
inline val UserWallet.isColdWallet get() = this is UserWallet.Cold
val UserWallet.isTangemPayCompatible: Boolean
get() = when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}

View file

@ -4,7 +4,13 @@ plugins {
}
dependencies {
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Domain
api(projects.domain.core)
api(projects.domain.models)
api(projects.domain.wallets.models)
// endregion
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.networks.multi
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -11,5 +12,16 @@ import com.tangem.domain.models.wallet.UserWalletId
*/
interface MultiNetworkStatusFetcher : FlowFetcher<MultiNetworkStatusFetcher.Params> {
data class Params(val userWalletId: UserWalletId, val networks: Set<Network>)
/**
* Params
*
* @property userWalletId user wallet id
* @property networks networks whose statuses are fetched
* @property extraTokens additional tokens to fetch balances for, beyond the wallet's added currencies
*/
data class Params(
val userWalletId: UserWalletId,
val networks: Set<Network>,
val extraTokens: Set<CryptoCurrency.Token> = emptySet(),
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.networks.single
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -15,7 +16,12 @@ interface SingleNetworkStatusFetcher : FlowFetcher<SingleNetworkStatusFetcher.Pa
* Params
*
* @property userWalletId user wallet id
* @property network network
* @property network network whose status is fetched
* @property extraTokens additional tokens to fetch balances for, beyond the wallet's added currencies
*/
data class Params(val userWalletId: UserWalletId, val network: Network)
data class Params(
val userWalletId: UserWalletId,
val network: Network,
val extraTokens: Set<CryptoCurrency.Token> = emptySet(),
)
}

View file

@ -5,8 +5,21 @@ 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(deps.kotlin.serialization)
// endregion
// region Domain
api(projects.domain.models)
// endregion
}

View file

@ -10,26 +10,31 @@ android {
}
dependencies {
// region Project Core
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Project Domain
// region Other libraries
api(deps.arrow.core)
// endregion
// region Core modules
api(projects.core.analytics.models)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.account)
api(projects.domain.networks)
api(projects.domain.quotes)
api(projects.domain.tokens)
api(projects.domain.wallets)
implementation(projects.domain.core)
implementation(projects.domain.account)
implementation(projects.domain.models)
implementation(projects.domain.networks)
implementation(projects.domain.nft.models)
implementation(projects.domain.quotes)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
// endregion
// region Others
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
// region Domain models
api(projects.domain.models)
api(projects.domain.nft.models)
// endregion
}

View file

@ -5,13 +5,12 @@ plugins {
}
dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
// region Kotlin
api(deps.kotlin.serialization)
// endregion
/* Utils */
implementation(deps.kotlin.serialization)
// region Domain models
api(projects.domain.models)
// endregion
}

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