Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-27 14:00:46 +05:00
commit 53ffcc2918
677 changed files with 33091 additions and 6980 deletions

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

@ -14,6 +14,7 @@ dependencies {
api(projects.domain.core)
api(projects.domain.models)
implementation(projects.domain.common)
implementation(projects.domain.transaction)
implementation(projects.domain.tokens)

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

@ -1,32 +1,42 @@
package com.tangem.domain.addressbook.usecase
package com.tangem.domain.addressbook.interactor
import arrow.core.Either
import arrow.core.right
import com.tangem.domain.addressbook.model.AddressEntriesVerification
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.hexToBytesOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones
* whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that
* were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase].
*
* An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex);
* such entries should be hidden from the user. Both partitions preserve the contact's original entry
* order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces
* as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all).
*
* Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]).
*/
class VerifyAddressEntriesUseCase(
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
class GetVerifiedContactsInteractor(
private val getContacts: GetContactsUseCase,
private val verifyMessages: VerifySecp256k1MessagesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
operator fun invoke(
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
return getContacts(query, userWalletId).map { contacts ->
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
contacts.mapNotNull { contact ->
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
VerifiedContact(
contact = contact.copy(addressEntries = verification.valid),
invalidEntries = verification.invalid,
)
}
}
}
private fun verify(
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
@ -40,7 +50,7 @@ class VerifyAddressEntriesUseCase(
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
val signatures = wellFormed.map { (_, signature) -> signature }
return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures)
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
.map { flags ->
val validIds = wellFormed
.filterIndexed { index, _ -> flags[index] }

View file

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

View file

@ -9,6 +9,7 @@ data class AddressEntry(
val id: AddressEntryId,
val address: String,
val networkId: Network.RawID,
val networkName: String,
val memo: String?,
val signature: String,
)

View file

@ -15,6 +15,8 @@ data class Contact(
val id: ContactId,
val walletId: UserWalletId,
val name: ContactName,
val icon: String,
val iconColor: String,
val createdAt: String,
val updatedAt: String,
val addressEntries: List<AddressEntry>,

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

@ -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,17 @@ import kotlinx.coroutines.flow.Flow
/** Persistence port for the address book. The implementation is provided by the data layer. */
interface AddressBookRepository {
/** Contacts for a single wallet. Each [Contact] keeps its own [Contact.walletId]. */
fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>>
/** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */
fun getContacts(userWalletIds: Set<UserWalletId>): Flow<List<Contact>>
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
fun getAllContacts(): Flow<List<Contact>>
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
/** 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

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

@ -4,10 +4,28 @@ import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetContactsUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(userWalletIds: Set<UserWalletId>): Flow<List<Contact>> = repository.getContacts(userWalletIds)
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<Contact>> {
val source = if (userWalletId == null) {
repository.getAllContacts()
} else {
repository.getContacts(userWalletId)
}
val normalizedQuery = query.trim()
if (normalizedQuery.isEmpty()) return source
return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } }
}
private fun Contact.matches(query: String): Boolean {
val isNameContaining = name.value.contains(other = query, ignoreCase = true)
val isAddressContaining = addressEntries.any { addressEntry ->
addressEntry.address.contains(other = query, ignoreCase = true)
}
return isNameContaining || isAddressContaining
}
}

View file

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

View file

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

@ -36,8 +36,16 @@ internal class AddressBookCipherTest {
fun `GIVEN multi-contact book WHEN encrypt then decrypt THEN original book is restored`() {
// Arrange
val book = addressBook(
contact("Alice", entry("addr-1", "0xabc", memo = "memo")),
contact("Bob", entry("addr-2", "0xdef", memo = null)),
contact(
name = "Alice",
iconColor = "TestColor1",
entries = arrayOf(entry("addr-1", "0xabc", memo = "memo")),
),
contact(
name = "Bob",
iconColor = "TestColor2",
entries = arrayOf(entry("addr-2", "0xdef", memo = null)),
),
)
// Act
@ -64,7 +72,13 @@ internal class AddressBookCipherTest {
@Test
fun `GIVEN a book WHEN encrypt THEN blob metadata and field sizes match the spec`() {
// Arrange
val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null)))
val book = addressBook(
contact(
name = "Alice",
iconColor = "TestColor",
entries = arrayOf(entry("addr-1", "0xabc", memo = null)),
)
)
// Act
val blob = cipher.encrypt(book, wallet, updatedAt).rightValue()
@ -94,7 +108,13 @@ internal class AddressBookCipherTest {
@Test
fun `GIVEN same book encrypted twice WHEN compared THEN nonce differs but both decrypt to original`() {
// Arrange
val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null)))
val book = addressBook(
contact(
name = "Alice",
iconColor = "TestColor",
entries = arrayOf(entry("addr-1", "0xabc", memo = null)),
)
)
// Act
val first = cipher.encrypt(book, wallet, updatedAt).rightValue()
@ -201,20 +221,42 @@ internal class AddressBookCipherTest {
assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.NoWalletPublicKey)
}
// region cross-platform vectors
// Shared known-answer vector, identical to iOS CommonAddressBookEncryptionServiceTests. Asserting the
// same bytes on both platforms guarantees a blob sealed on one opens on the other. Do not change these
// constants without changing the iOS suite in lockstep.
@Test
fun `GIVEN fixed public key WHEN deriveAesKey THEN matches the locked HMAC-SHA256 vector`() {
// Arrange — independently computed: HMAC-SHA256(SHA-256([01,02,03,04]), "TokensSymmetricKey")
val publicKey = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val expected = "da48094b89902e137ae73ae90acbd809af9ad4f648044c17e7ee6de73e96b0c2"
fun `GIVEN shared cross-platform public key WHEN deriveAesKey THEN matches the iOS vector`() {
// Arrange
val publicKey = VECTOR_PUBLIC_KEY_HEX.hexToBytes()
// Act
val aesKey = AddressBookKeyDerivation.deriveAesKey(publicKey)
// Assert
assertThat(aesKey).hasLength(AES_256_KEY_BYTES)
assertThat(aesKey.toHexString().lowercase()).isEqualTo(expected)
assertThat(aesKey.toHexString().lowercase()).isEqualTo(VECTOR_KEY_HEX)
}
@Test
fun `GIVEN a blob sealed on the other platform WHEN decrypt with the derived key THEN restores the plaintext`() {
// Arrange — open the iOS-produced AES-256-GCM box with the key derived from the shared seed
val aesKey = AddressBookKeyDerivation.deriveAesKey(VECTOR_PUBLIC_KEY_HEX.hexToBytes())
// Act
val plaintext = aesGcmOpen(
key = aesKey,
nonce = VECTOR_NONCE_HEX.hexToBytes(),
ciphertext = VECTOR_CIPHERTEXT_HEX.hexToBytes(),
authTag = VECTOR_TAG_HEX.hexToBytes(),
)
// Assert
assertThat(plaintext.toString(Charsets.UTF_8)).isEqualTo(VECTOR_PLAINTEXT)
}
// endregion
// region helpers
private fun addressBook(vararg contacts: Contact): AddressBook =
AddressBook(walletId = wallet.walletId, contacts = contacts.toList())
@ -222,10 +264,12 @@ internal class AddressBookCipherTest {
private fun addressBook(walletId: UserWalletId): AddressBook =
AddressBook(walletId = walletId, contacts = emptyList())
private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact(
private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-$name"),
walletId = wallet.walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = iconColor,
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-05-22T09:00:00.000Z",
addressEntries = entries.toList(),
@ -237,10 +281,19 @@ internal class AddressBookCipherTest {
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
networkName = "Ethereum",
)
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
/** Raw AES-256-GCM open, mirroring [AddressBookCipher]'s transformation and tag size. */
private fun aesGcmOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, authTag: ByteArray): ByteArray =
Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
}.doFinal(ciphertext + authTag)
private fun <T> Either<AddressBookCryptoError, T>.rightValue(): T =
getOrNull() ?: error("Expected Either.Right but was $this")
@ -272,5 +325,13 @@ internal class AddressBookCipherTest {
const val NONCE_HEX_LENGTH = NONCE_BYTES * 2
const val TAG_HEX_LENGTH = TAG_BYTES * 2
const val AES_256_KEY_BYTES = 32
// Shared cross-platform known-answer vector (see iOS CommonAddressBookEncryptionServiceTests).
const val VECTOR_PUBLIC_KEY_HEX = "0374d0f81f42ddfe34114d533e95e6ae5fe6ea271c96f1fa505199fdc365ae9720"
const val VECTOR_KEY_HEX = "59b85ce53fac0a8493d9d8d9c0d32adb5f586741dd8bbfd9348a3212e493730d"
const val VECTOR_NONCE_HEX = "000102030405060708090a0b"
const val VECTOR_CIPHERTEXT_HEX = "f4ee0f404e747b5b5cca730c44baf86ca3d8f6fbdf66ff2fe98d3b8f88cb23df7ff55b52205f32c8ab"
const val VECTOR_TAG_HEX = "6c4b71b27958f43afc6633850369a17a"
const val VECTOR_PLAINTEXT = "Tangem Address Book cross-platform vector"
}
}

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(addressEntries = listOf(valid)),
invalidEntries = listOf(invalid),
),
)
}
@Test
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest {
// Arrange
val contact = contact(
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
)
stubContacts(contact)
val messagesSlot = slot<List<ByteArray>>()
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
} returns listOf(true, true).right()
// Act
interactor(query = "").first()
// Assert
assertThat(messagesSlot.captured.map { String(it) })
.containsExactly(
expectedPayload(contact, contact.addressEntries[0]),
expectedPayload(contact, contact.addressEntries[1]),
)
.inOrder()
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
}
@Test
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest {
// Arrange
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
val contact = contact(valid1, invalid, valid2)
stubContacts(contact)
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
// Act
val result = interactor(query = "").first().single()
// Assert
assertThat(result.contact.addressEntries).containsExactly(valid1, valid2).inOrder()
assertThat(result.invalidEntries).containsExactly(invalid)
}
@Test
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest {
// Arrange
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
val contact = contact(malformed, signed)
stubContacts(contact)
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
} returns listOf(true).right()
// Act
val result = interactor(query = "").first().single()
// Assert
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
assertThat(result.contact.addressEntries).containsExactly(signed)
assertThat(result.invalidEntries).containsExactly(malformed)
}
@Test
fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest {
// Arrange
val contact = contact()
stubContacts(contact)
// Act
val result = interactor(query = "").first().single()
// Assert
assertThat(result.contact.addressEntries).isEmpty()
assertThat(result.invalidEntries).isEmpty()
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
}
@Test
fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest {
// Arrange
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
// Act
val result = interactor(query = "").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest {
// Arrange
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
// Act
val result = interactor(query = "").first()
// Assert
assertThat(result).isEmpty()
}
private fun stubContacts(vararg contacts: Contact) {
every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList())
}
private fun contact(vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-1"),
walletId = walletId,
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = memo,
signature = signature,
)
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
}

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.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString())
}
@Test
fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() =
runTest {
// Arrange
stubNoExistingContacts()
val twoEntries = listOf(
entry(id = "addr-1", address = "0xabc", memo = "memo"),
entry(id = "addr-2", address = "0xdef", memo = null),
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>()
val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.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.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString(), signatures[1].toHexString())
.inOrder()
}
@Test
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
// Arrange
stubNoExistingContacts()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList())
// Assert
assertThat(result.getOrNull()).isEqualTo(saved.captured)
assertThat(saved.captured.addressEntries).isEmpty()
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
fun `GIVEN wallet without a secp256k1 key WHEN createContact THEN Signing NoSigningKey without persisting`() =
runTest {
// Arrange — a locked hot wallet exposes no key; validation must still pass first
val lockedWallet = mockk<UserWallet.Hot> {
every { walletId } returns userWallet.walletId
every { wallets } returns null
}
stubNoExistingContacts()
// Act
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled")))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest {
// Arrange
every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice")))
// Act
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
// Arrange
stubNoExistingContacts()
// Act
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@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",
addressEntries = updatedEntries,
)
// Assert
val contact = result.getOrNull()
assertThat(contact).isEqualTo(saved.captured)
assertThat(contact!!.id).isEqualTo(existing.id)
assertThat(contact.name.value).isEqualTo("Bob")
assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP)
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
assertThat(contact.addressEntries.map { it.signature })
.containsExactly(signatures[0].toHexString())
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
}
@Test
fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest {
// Arrange
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
SignHashesError.NoSigningKey.left()
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "Bob",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN blank name WHEN updateContact THEN Name Format without persisting`() = runTest {
// Act
val result = interactor.updateContact(
userWallet = userWallet,
contact = contact(name = "Alice"),
name = "",
iconColor = "TestColor",
addressEntries = updatedEntries,
)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
coVerify(exactly = 0) { repository.saveContact(any()) }
}
}
private fun contact(name: String): Contact = Contact(
id = ContactId("id-$name"),
walletId = userWallet.walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = ORIGINAL_TIMESTAMP,
updatedAt = ORIGINAL_TIMESTAMP,
addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)),
)
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = networkRawId,
memo = memo,
signature = "sig",
networkName = "Ethereum",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
private companion object {
const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z"
const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z"
}
}

View file

@ -1,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,108 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetContactsUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactsUseCase(repository)
private val alice = contact(name = "Alice", address = "0xaaa")
private val bob = contact(name = "Bob", address = "0xbbb")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
every { repository.getAllContacts() } returns flowOf(listOf(alice, bob))
}
@Test
fun `GIVEN query matches a name WHEN invoke THEN returns only matching contacts`() = runTest {
// Act
val result = useCase(query = "ali").first()
// Assert
assertThat(result).containsExactly(alice)
}
@Test
fun `GIVEN query matches an address WHEN invoke THEN returns only matching contacts`() = runTest {
// Act
val result = useCase(query = "0xbbb").first()
// Assert
assertThat(result).containsExactly(bob)
}
@Test
fun `GIVEN blank query WHEN invoke THEN returns all contacts unfiltered`() = runTest {
// Act
val result = useCase(query = " ").first()
// Assert
assertThat(result).containsExactly(alice, bob)
}
@Test
fun `GIVEN query matches nothing WHEN invoke THEN returns empty list`() = runTest {
// Act
val result = useCase(query = "charlie").first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest {
// Arrange
val walletId = UserWalletId("011")
every { repository.getContacts(walletId) } returns flowOf(listOf(alice))
// Act
val result = useCase(query = "", userWalletId = walletId).first()
// Assert
assertThat(result).containsExactly(alice)
verify(exactly = 1) { repository.getContacts(walletId) }
verify(exactly = 0) { repository.getAllContacts() }
}
private fun contact(name: String, address: String): Contact = Contact(
id = ContactId("id-$name"),
walletId = UserWalletId("011"),
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)
}

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,6 +64,8 @@ class ValidateContactNameUseCaseTest {
id = ContactId("id-$name"),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "KekColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
@ -73,6 +75,7 @@ class ValidateContactNameUseCaseTest {
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)

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

@ -7,5 +7,7 @@ plugins {
dependencies {
implementation(deps.moshi.adapters)
implementation(deps.kotlin.serialization)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.onramp.models)
}

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
@ -15,6 +16,7 @@ import com.tangem.domain.tokens.model.AmountType
* @property payoutHash On-chain hash of the payout (received) leg, if known.
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
* @property country The country the onramp was made from; `null` if not resolved.
*/
data class OnrampTransaction(
val txId: String,
@ -25,4 +27,5 @@ data class OnrampTransaction(
/** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount,
val toAsset: ExpressTransactionAsset,
val country: OnrampCountry? = null,
)

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

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

@ -2,5 +2,4 @@ package com.tangem.domain.pushnotificationpreferences.models
data class PushNotificationPreference(
val isEnabled: Boolean,
val isVisible: Boolean,
)

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.transaction"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
@ -42,6 +46,8 @@ dependencies {
implementation(projects.domain.notifications)
api(projects.domain.networks)
testRuntimeOnly(deps.test.junit5.engine)
testRuntimeOnly(deps.test.junit5.vintage.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)

View file

@ -18,6 +18,7 @@ sealed class GetFeeError {
data object NetworkIsNotSupported : GaslessError()
data object NoSupportedTokensFound : GaslessError()
data object NotEnoughFunds : GaslessError()
data object ModuleUpdateUnavailable : GaslessError()
data class DataError(val cause: Throwable?) : GaslessError()
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.transaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger
@ -57,6 +58,33 @@ interface GaslessTransactionRepository {
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Sends a gasless BATCH transaction to the gasless service for signing and returns the signed result.
*
* Mirrors [signGaslessTransaction] but accepts multiple transactions executed in array order.
* Index 0 is the user's main transaction; subsequent entries are appended operations
* (e.g. a yield `withdraw` to cover the fee from staked balance).
*
* @param gaslessBatchTransactionData domain model containing:
* - transactions: ordered list of calls (to, value, data)
* - fee: token payment configuration
* - nonce: user's contract nonce to prevent replay attacks
* @param signature user's ECDSA signature of the batch transaction in hex format (0x...)
* @param userAddress user's Ethereum address (EOA or contract wallet)
* @param network blockchain network used to determine chainId for the request
* @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract
* @return [GaslessSignedTransactionResult] containing the fully signed transaction ready to broadcast
* @throws IllegalStateException if network is not supported or chainId cannot be determined
* @throws Exception if service returns error or network request fails
*/
suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization? = null,
): GaslessSignedTransactionResult
/**
* Hardcoded value as baseGas
*/

View file

@ -0,0 +1,33 @@
package com.tangem.domain.transaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
/**
* Narrow repository interface used by [com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase]
* to query yield-module state without introducing a circular module dependency.
*
* [com.tangem.domain.yield.supply.YieldSupplyTransactionRepository] extends this interface.
*/
interface GaslessYieldRepository {
/** Returns the effective (liquid) protocol balance for [cryptoCurrency], or null if unavailable. */
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/** Returns the yield-module contract address for [cryptoCurrency], or null if unavailable. */
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
/**
* Builds an upgrade-wrapped `withdraw(yieldToken, amount)` call data for the user's yield module.
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
* @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
*/
suspend fun createPartialWithdrawCallData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
amount: Amount,
): SmartContractCallData
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.transaction.models
import java.math.BigInteger
/**
* Domain model for a gasless BATCH transaction (EIP-712 primaryType `GaslessBatchTransaction`).
* Reuses [GaslessTransactionData.Transaction] and [GaslessTransactionData.Fee].
*
* @property transactions ordered list index 0 is the user's main transaction, subsequent entries
* are appended operations (e.g. the yield `withdraw`). Executed in array order.
* @property fee fee payment configuration.
* @property nonce nonce from the user's contract.
*/
data class GaslessBatchTransactionData(
val transactions: List<GaslessTransactionData.Transaction>,
val fee: GaslessTransactionData.Fee,
val nonce: BigInteger,
)

View file

@ -0,0 +1,39 @@
package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
/**
* Resolved strategy for paying a gasless transaction fee. Produced by ResolveGaslessFeePlanUseCase,
* consumed by CreateAndSendGaslessTransactionUseCase.
*/
sealed interface GaslessFeePlan {
/** Pay in the native coin (enough native balance) — falls back to the standard fee. */
data class NativePay(val fee: Fee) : GaslessFeePlan
/** Pay the fee from the token's plain balance. */
data class TokenPay(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
) : GaslessFeePlan
/**
* Pay the fee by first withdrawing the token from the user's yield module (appended as a second
* batch transaction). [withdrawCallData] is already upgrade-wrapped when the module needs an upgrade.
*
* Note: the executed on-chain withdraw amount is the (floor-rounded) value encoded inside
* [withdrawCallData]. [withdrawAmount] is a CEILING-rounded copy intended for DISPLAY (e.g. a future
* "X withdrawn from Yield" notification); it intentionally may exceed the executed amount by 1 base
* unit. Do NOT use [withdrawAmount] to build the on-chain call data.
*/
data class TokenPayWithYieldWithdraw(
val feeToken: CryptoCurrency.Token,
val fee: Fee.Ethereum.TokenCurrency,
val withdrawAmount: BigInteger,
val withdrawCallData: SmartContractCallData,
val yieldModuleAddress: String,
) : GaslessFeePlan
}

View file

@ -15,16 +15,11 @@ data class GaslessTransactionData(
val nonce: BigInteger,
) {
/**
* Core transaction data.
*
* @property to destination address
* @property value transaction value in wei (currently always 0 for gasless)
* @property data encoded transaction data (contract call)
*/
data class Transaction(
val to: String,
val value: BigInteger,
val gasLimit: BigInteger,
val data: ByteArray,
) {
override fun equals(other: Any?): Boolean {
@ -35,6 +30,7 @@ data class GaslessTransactionData(
if (to != other.to) return false
if (value != other.value) return false
if (gasLimit != other.gasLimit) return false
if (!data.contentEquals(other.data)) return false
return true
@ -43,6 +39,7 @@ data class GaslessTransactionData(
override fun hashCode(): Int {
var result = to.hashCode()
result = 31 * result + value.hashCode()
result = 31 * result + gasLimit.hashCode()
result = 31 * result + data.contentHashCode()
return result
}

View file

@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigInteger
data class TransactionFeeExtended(
val transactionFee: TransactionFee,
val feeTokenId: CryptoCurrency.ID,
/**
* Resolved gasless fee strategy. Non-null only for token-paid gasless fees; null for native fee.
* A null value is semantically equivalent to [GaslessFeePlan.NativePay] consumers MUST treat them
* the same. [GaslessFeePlan.NativePay] is produced only by ResolveGaslessFeePlanUseCase.
* When it is [GaslessFeePlan.TokenPayWithYieldWithdraw], the send step builds a batch transaction.
*/
val gaslessFeePlan: GaslessFeePlan? = null,
/**
* Per-call gas limit for the user's main transaction, bound into the v2 EIP-712 hash
* ([GaslessTransactionData.Transaction.gasLimit]). Non-null only on the token-fee (gasless) path,
* where it equals the estimated execution gas of the user's transaction.
*/
val mainTransactionGasLimit: BigInteger? = null,
/**
* Per-call gas limit for the appended yield-withdraw sub-call in a batch. Non-null only when the
* fee is paid via [GaslessFeePlan.TokenPayWithYieldWithdraw]; used as the withdraw transaction's
* [GaslessTransactionData.Transaction.gasLimit].
*/
val withdrawGasLimit: BigInteger? = null,
)

View file

@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
private val isGaslessV2Enabled: Boolean,
) {
suspend operator fun invoke(
@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Prepares all necessary context for gasless transaction.
* Includes: wallet manager, gasless provider, token status, nonce, transaction data.
*
* When the resolved fee plan is [GaslessFeePlan.TokenPayWithYieldWithdraw], the payload is a
* [GaslessPayload.Batch] with the user's main tx at index 0 and the yield-withdraw tx at index 1.
* [GaslessFeePlan.TokenPay] and a null plan produce a [GaslessPayload.Single] with the same
* single-transaction behavior as before. [GaslessFeePlan.NativePay] must never reach this use
* case it is guarded in [assembleGaslessPayload].
*/
private suspend fun prepareGaslessContext(
userWallet: UserWallet,
@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase(
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val gaslessTransactionData = createGaslessTransactionData(
transactionData = transactionData,
txFee = fee,
currency = currency,
val mainTxGasLimit = fee.mainTransactionGasLimit
?: error("Main transaction gas limit is required for a gasless (token-fee) transaction")
val mainTx = buildTransaction(transactionData, mainTxGasLimit)
val feeObj = buildFee(fee, currency)
val payload = assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = gaslessContractNonce,
plan = fee.gaslessFeePlan,
withdrawGasLimit = fee.withdrawGasLimit,
)
val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network)
@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase(
walletManager = walletManager,
gaslessDataProvider = gaslessDataProvider,
currency = currency,
gaslessTransactionData = gaslessTransactionData,
payload = payload,
chainId = chainId,
)
}
@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Signs gasless transaction and EIP-7702 authorization.
* Returns prepared signatures and authorization data.
*
* EIP-712 typed data is constructed from the payload:
* - [GaslessPayload.Single] [Eip712TypedDataBuilder.build] (single-transaction schema)
* - [GaslessPayload.Batch] [Eip712TypedDataBuilder.buildBatch] (batch schema)
*/
private suspend fun signGaslessTransactionByUser(
userWallet: UserWallet,
context: GaslessContext,
transactionData: TransactionData.Uncompiled,
): SignedGaslessData {
val eip712Data = Eip712TypedDataBuilder.build(
gaslessTransaction = context.gaslessTransactionData,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
)
val eip712Data = when (val payload = context.payload) {
is GaslessPayload.Single -> Eip712TypedDataBuilder.build(
gaslessTransaction = payload.data,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
is GaslessPayload.Batch -> Eip712TypedDataBuilder.buildBatch(
gaslessBatch = payload.data,
chainId = context.chainId,
verifyingContract = transactionData.sourceAddress,
includeGasLimit = isGaslessV2Enabled,
)
}
val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data)
val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider)
@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase(
/**
* Sends gasless transaction to the service.
*
* Routes to the appropriate repository call based on payload type:
* - [GaslessPayload.Single] [GaslessTransactionRepository.signGaslessTransaction]
* - [GaslessPayload.Batch] [GaslessTransactionRepository.signGaslessBatchTransaction]
*
* Pending-transaction tracking is always keyed on the main (user's) transaction only.
*/
private suspend fun signAndSendTransactionOnBackend(
context: GaslessContext,
signedData: SignedGaslessData,
transactionData: TransactionData.Uncompiled,
): String {
val txHash = gaslessTransactionRepository.signGaslessTransaction(
network = context.currency.network,
gaslessTransactionData = context.gaslessTransactionData,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
val txHash = when (val payload = context.payload) {
is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction(
network = context.currency.network,
gaslessTransactionData = payload.data,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
is GaslessPayload.Batch -> gaslessTransactionRepository.signGaslessBatchTransaction(
network = context.currency.network,
gaslessBatchTransactionData = payload.data,
signature = signedData.eip712Signature,
userAddress = transactionData.sourceAddress,
eip7702Auth = signedData.eip7702Auth,
).txHash
}
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData,
@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase(
}
}
private suspend fun createGaslessTransactionData(
private fun buildTransaction(
transactionData: TransactionData.Uncompiled,
txFee: TransactionFeeExtended,
currency: CryptoCurrency,
nonce: BigInteger,
): GaslessTransactionData {
val transaction = buildTransaction(transactionData)
val fee = buildFee(txFee, currency)
return GaslessTransactionData(
transaction = transaction,
fee = fee,
nonce = nonce,
)
}
private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction {
gasLimit: BigInteger,
): GaslessTransactionData.Transaction {
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required")
@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase(
return GaslessTransactionData.Transaction(
to = getDestinationAddress(transactionData),
value = nativeAmount,
gasLimit = gasLimit,
data = callData.data,
)
}
@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase(
private suspend fun getEIP7702DataForGasless(
gaslessDataProvider: EthereumGaslessDataProvider,
): EIP7702AuthorizationData {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) {
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = isGaslessV2Enabled)) {
is Result.Failure -> throw dataResult.error
is Result.Success -> dataResult.data
}
}
private fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
val contractAddress = txData.contractAddress
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
ethereumCallData.destinationAddress
} else {
contractAddress ?: error("supports only Token transaction with contract address")
}
/**
* Discriminated union of the gasless transaction payload to sign and send.
*
* [Single] carries a single-transaction payload (the pre-existing path).
* [Batch] carries a batch payload where the yield-withdraw call is appended as the second
* transaction so that staked tokens are unlocked before the fee is settled.
*/
internal sealed interface GaslessPayload {
/** Single-transaction path — behavior is identical to the original implementation. */
data class Single(val data: GaslessTransactionData) : GaslessPayload
/**
* Batch path used when [GaslessFeePlan.TokenPayWithYieldWithdraw] is resolved.
* [data.transactions] has the user's main tx at index 0 and the withdraw tx at index 1.
*/
data class Batch(val data: GaslessBatchTransactionData) : GaslessPayload
}
/**
@ -318,7 +357,7 @@ class CreateAndSendGaslessTransactionUseCase(
val walletManager: WalletManager,
val gaslessDataProvider: EthereumGaslessDataProvider,
val currency: CryptoCurrency,
val gaslessTransactionData: GaslessTransactionData,
val payload: GaslessPayload,
val chainId: Int,
)
@ -353,9 +392,75 @@ class CreateAndSendGaslessTransactionUseCase(
}
}
private companion object {
internal companion object {
/**
* Assembles the [GaslessPayload] from already-built domain objects and the resolved fee plan.
*
* Dispatch rules:
* - [GaslessFeePlan.TokenPayWithYieldWithdraw] [GaslessPayload.Batch]: the yield-withdraw
* call is appended as the second transaction so that the fee token balance is topped up
* before the gasless service processes the fee.
* - [GaslessFeePlan.TokenPay] or `null` [GaslessPayload.Single]: single-transaction path,
* identical to the original implementation. `null` is a legitimate value meaning the plan
* was not explicitly resolved.
* - [GaslessFeePlan.NativePay] error: native-pay fees must never reach this use case
* (they are handled by the standard send path).
*/
internal fun assembleGaslessPayload(
mainTx: GaslessTransactionData.Transaction,
feeObj: GaslessTransactionData.Fee,
nonce: BigInteger,
plan: GaslessFeePlan?,
withdrawGasLimit: BigInteger?,
): GaslessPayload = when (plan) {
is GaslessFeePlan.TokenPayWithYieldWithdraw -> GaslessPayload.Batch(
GaslessBatchTransactionData(
transactions = listOf(
mainTx,
GaslessTransactionData.Transaction(
to = plan.yieldModuleAddress,
value = BigInteger.ZERO,
gasLimit = withdrawGasLimit
?: error("Withdraw gas limit is required for a yield-withdraw batch"),
data = plan.withdrawCallData.data,
),
),
fee = feeObj,
nonce = nonce,
),
)
is GaslessFeePlan.TokenPay, null -> GaslessPayload.Single(
GaslessTransactionData(transaction = mainTx, fee = feeObj, nonce = nonce),
)
is GaslessFeePlan.NativePay -> error("NativePay must not reach the gasless send path")
}
fun BigInteger.toFormattedHex(bytes: Int): String {
return toByteArray().normalizeByteArray(bytes).toHexString().formatHex()
}
/**
* Resolves the on-chain `to` for the user's main gasless sub-call.
*
* - Yield-supply send (`EthereumYieldSupplySendCallData`, selector 0x0779afe6): `send(token, dest,
* amount)` is a method ON the user's yield module the executor must CALL the module (it holds the
* staked funds and routes the transfer); the recipient is already encoded inside the call data.
* [TransactionData.Uncompiled.destinationAddress] is patched to the module address in
* `DefaultTransactionRepository.createTransaction`, mirroring the non-gasless send path (and the
* withdraw sub-call's `to`). Reading `ethereumCallData.destinationAddress` (the recipient) instead
* makes the executor call a plain address with the module's calldata, reverting the whole batch with
* GAS_ESTIMATION_FAILED / require(false).
* - Otherwise (e.g. ERC-20 transfer): `to` is the contract the calldata runs against
* ([TransactionData.Uncompiled.contractAddress], the token contract).
*/
internal fun getDestinationAddress(txData: TransactionData.Uncompiled): String {
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
txData.destinationAddress
} else {
txData.contractAddress ?: error("supports only Token transaction with contract address")
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.common.extensions.toHexString
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONArray
import org.json.JSONObject
@ -26,6 +27,7 @@ object Eip712TypedDataBuilder {
private const val DOMAIN_NAME = "Tangem7702GaslessExecutor"
private const val DOMAIN_VERSION = "1"
private const val PRIMARY_TYPE = "GaslessTransaction"
private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction"
/**
* Builds EIP-712 typed data JSON for gasless transaction.
@ -35,47 +37,106 @@ object Eip712TypedDataBuilder {
* @param verifyingContract address of the deployed gasless executor contract
* @return JSON string ready for EIP-712 signing
*/
fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String {
fun build(
gaslessTransaction: GaslessTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
val typedData = JSONObject().apply {
put("types", buildTypes())
put("types", buildTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildMessage(gaslessTransaction))
put("message", buildMessage(gaslessTransaction, includeGasLimit))
}
return typedData.toString()
}
/**
* Builds EIP-712 typed data JSON for gasless batch transaction.
*
* @param gaslessBatch domain model with ordered list of transactions and fee data
* @param chainId blockchain network chain ID
* @param verifyingContract address of the deployed gasless executor contract
* @return JSON string ready for EIP-712 signing
*/
fun buildBatch(
gaslessBatch: GaslessBatchTransactionData,
chainId: Int,
verifyingContract: String,
includeGasLimit: Boolean = true,
): String {
require(
gaslessBatch.transactions.isNotEmpty(),
) { "GaslessBatchTransaction must contain at least one transaction" }
val typedData = JSONObject().apply {
put("types", buildBatchTypes(includeGasLimit))
put("primaryType", PRIMARY_TYPE_BATCH)
put("domain", buildDomain(chainId, verifyingContract))
put("message", buildBatchMessage(gaslessBatch, includeGasLimit))
}
return typedData.toString()
}
/**
* Builds the type definitions for all structures in the batch variant.
* Uses `Transaction[]` for the ordered transactions array.
*/
private fun buildBatchTypes(includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("EIP712Domain", buildEip712DomainTypeProperties())
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put("Fee", buildFeeTypeProperties())
put("GaslessBatchTransaction", buildGaslessBatchTransactionTypeProperties())
}
}
private fun buildGaslessBatchTransactionTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("transactions", "Transaction[]"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
}
}
/**
* Builds the message data from gasless batch transaction.
*/
private fun buildBatchMessage(gaslessBatch: GaslessBatchTransactionData, includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("transactions", buildTransactionsArray(gaslessBatch.transactions, includeGasLimit))
put("fee", buildFeeMessage(gaslessBatch.fee))
put("nonce", gaslessBatch.nonce.toString())
}
}
private fun buildTransactionsArray(
transactions: List<GaslessTransactionData.Transaction>,
includeGasLimit: Boolean,
): JSONArray {
return JSONArray().apply {
transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) }
}
}
/**
* Builds the type definitions for all structures.
* This schema is fixed and defines the structure of the data being signed.
*/
@Suppress("NestedScopeFunctions")
private fun buildTypes(): JSONObject {
private fun buildTypes(includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("EIP712Domain", JSONArray().apply {
put(typeProperty("name", "string"))
put(typeProperty("version", "string"))
put(typeProperty("chainId", "uint256"))
put(typeProperty("verifyingContract", "address"))
})
put("Transaction", JSONArray().apply {
put(typeProperty("to", "address"))
put(typeProperty("value", "uint256"))
put(typeProperty("data", "bytes"))
})
put("Fee", JSONArray().apply {
put(typeProperty("feeToken", "address"))
put(typeProperty("maxTokenFee", "uint256"))
put(typeProperty("coinPriceInToken", "uint256"))
put(typeProperty("feeTransferGasLimit", "uint256"))
put(typeProperty("baseGas", "uint256"))
put(typeProperty("feeReceiver", "address"))
})
put("GaslessTransaction", JSONArray().apply {
put(typeProperty("transaction", "Transaction"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
})
put("EIP712Domain", buildEip712DomainTypeProperties())
put("Transaction", buildTransactionTypeProperties(includeGasLimit))
put("Fee", buildFeeTypeProperties())
put("GaslessTransaction", buildGaslessTransactionTypeProperties())
}
}
private fun buildGaslessTransactionTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("transaction", "Transaction"))
put(typeProperty("fee", "Fee"))
put(typeProperty("nonce", "uint256"))
}
}
@ -104,23 +165,71 @@ object Eip712TypedDataBuilder {
/**
* Builds the message data from gasless transaction.
*/
@Suppress("NestedScopeFunctions")
private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject {
private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): JSONObject {
return JSONObject().apply {
put("transaction", JSONObject().apply {
put("to", gaslessTransaction.transaction.to)
put("value", gaslessTransaction.transaction.value.toString())
put("data", gaslessTransaction.transaction.data.toHexString())
})
put("fee", JSONObject().apply {
put("feeToken", gaslessTransaction.fee.feeToken)
put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString())
put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString())
put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString())
put("baseGas", gaslessTransaction.fee.baseGas.toString())
put("feeReceiver", gaslessTransaction.fee.feeReceiver)
})
put("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit))
put("fee", buildFeeMessage(gaslessTransaction.fee))
put("nonce", gaslessTransaction.nonce.toString())
}
}
private fun buildTransactionMessage(
transaction: GaslessTransactionData.Transaction,
includeGasLimit: Boolean,
): JSONObject {
return JSONObject().apply {
put("to", transaction.to)
put("value", transaction.value.toString())
if (includeGasLimit) put("gasLimit", transaction.gasLimit.toString())
put("data", transaction.data.toHexString())
}
}
// region Shared type schema helpers
private fun buildEip712DomainTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("name", "string"))
put(typeProperty("version", "string"))
put(typeProperty("chainId", "uint256"))
put(typeProperty("verifyingContract", "address"))
}
}
private fun buildTransactionTypeProperties(includeGasLimit: Boolean): JSONArray {
return JSONArray().apply {
put(typeProperty("to", "address"))
put(typeProperty("value", "uint256"))
if (includeGasLimit) put(typeProperty("gasLimit", "uint256"))
put(typeProperty("data", "bytes"))
}
}
private fun buildFeeTypeProperties(): JSONArray {
return JSONArray().apply {
put(typeProperty("feeToken", "address"))
put(typeProperty("maxTokenFee", "uint256"))
put(typeProperty("coinPriceInToken", "uint256"))
put(typeProperty("feeTransferGasLimit", "uint256"))
put(typeProperty("baseGas", "uint256"))
put(typeProperty("feeReceiver", "address"))
}
}
// endregion
// region Shared message helpers
private fun buildFeeMessage(fee: GaslessTransactionData.Fee): JSONObject {
return JSONObject().apply {
put("feeToken", fee.feeToken)
put("maxTokenFee", fee.maxTokenFee.toString())
put("coinPriceInToken", fee.coinPriceInToken.toString())
put("feeTransferGasLimit", fee.feeTransferGasLimit.toString())
put("baseGas", fee.baseGas.toString())
put("feeReceiver", fee.feeReceiver)
}
}
// endregion
}

View file

@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
@Suppress("LongParameterList")
@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val estimateFeeUseCase: EstimateFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.filterNot { it.value.amount?.isZero() == true || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
@Suppress("LongParameterList")
class EstimateFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase(
val walletManager = prepareWalletManager(userWallet, token.network)
val isYieldActive = isYieldWithdrawEnabled &&
feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = feeTokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
isYieldActive = isYieldActive,
).bind()
},
catch = {

View file

@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
private val isYieldWithdrawEnabled: Boolean,
) {
/**
@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase(
}.toSet()
return userCurrenciesStatuses
.asSequence()
.filter { it.value.yieldSupplyStatus == null }
.filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) }
.filter { it.currency.network.id == network.id }
.filter { currencyStatus ->
val token = currencyStatus.currency
@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase(
}
.toList()
}
internal companion object {
internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean {
val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true
return isYieldWithdrawEnabled && yieldSupplyStatus.isActive
}
}
}

View file

@ -6,6 +6,7 @@ import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
@ -19,6 +20,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getFeeUseCase: GetFeeUseCase,
private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase(
).bind()
selectFeePaymentStrategy(
userWallet = userWallet,
accountStatusList = accountStatusList,
walletManager = walletManager,
nativeCurrencyStatus = nativeCurrencyStatus,
network = network,
initialFee = initialFee,
transactionData = transactionData,
)
},
catch = {
@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase(
return ethereumWalletManager
}
@Suppress("LongParameterList")
private suspend fun Raise<GetFeeError>.selectFeePaymentStrategy(
userWallet: UserWallet,
accountStatusList: AccountStatusList,
walletManager: EthereumWalletManager,
nativeCurrencyStatus: CryptoCurrencyStatus,
network: Network,
initialFee: TransactionFee,
transactionData: TransactionData,
): TransactionFeeExtended {
val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError)
@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase(
nativeCoinSelectedResult
} else {
findTokensToPayFee(
userWallet = userWallet,
walletManager = walletManager,
initialTxFee = initialFee,
nativeCurrencyStatus = nativeCurrencyStatus,
networkCurrenciesStatuses = networkCurrenciesStatuses,
transactionData = transactionData,
).getOrElse { error ->
when (error) {
GaslessError.NotEnoughFunds -> nativeCoinSelectedResult
@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase(
}
}
@Suppress("NullableToStringCall")
@Suppress("NullableToStringCall", "LongParameterList")
private suspend fun findTokensToPayFee(
userWallet: UserWallet,
walletManager: EthereumWalletManager,
initialTxFee: TransactionFee,
nativeCurrencyStatus: CryptoCurrencyStatus,
networkCurrenciesStatuses: List<CryptoCurrencyStatus>,
transactionData: TransactionData,
): Either<GetFeeError, TransactionFeeExtended> = either {
val initialFee = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase(
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens(
network = nativeCurrencyStatus.currency.network,
).mapNotNull { currency ->
(currency as? CryptoCurrency.Token)?.contractAddress
(currency as? CryptoCurrency.Token)?.contractAddress?.lowercase()
}.toSet()
val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses
.filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token }
.sortedByDescending { it.value.amount }
.filter { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@filter false
token.contractAddress.lowercase() in supportedGaslessTokens
}
/**
* Selects token with highest balance to maximize chances of successful fee payment.
* Returns null if no suitable tokens found.
* Yield-aware candidate selection:
* a token is eligible if it is a supported gasless token AND
* (total balance > 0 OR has an active yield position).
* Sorted by total balance descending to maximise chances of covering the fee. For a yield token
* value.amount is already effectiveBalance (liquid EOA + effectiveProtocolBalance), so it must NOT
* be summed with effectiveProtocolBalance again that would double-count the module portion.
*/
val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull()
?: raise(GaslessError.NoSupportedTokensFound)
val candidates = networkCurrenciesStatuses
.asSequence()
.filter { it.currency is CryptoCurrency.Token }
.filter { (it.currency as CryptoCurrency.Token).contractAddress.lowercase() in supportedGaslessTokens }
.filter { status ->
val total = status.value.amount ?: BigDecimal.ZERO
total > BigDecimal.ZERO || isYieldWithdrawEnabled && status.value.yieldSupplyStatus?.isActive == true
}
.sortedByDescending { status -> status.value.amount ?: BigDecimal.ZERO }
return tokenFeeCalculator.calculateTokenFee(
val tokenForPayFeeStatus = candidates.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound)
val isYieldActive = isYieldWithdrawEnabled && tokenForPayFeeStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenForPayFeeStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFee,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind()
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenForPayFeeStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = isYieldActive,
)
}
}
/**
* Resolves the [com.tangem.domain.transaction.models.GaslessFeePlan] for [tokenStatus] paying the gasless
* fee and attaches it to [tokenFeeExtended]. Shared by the auto path ([GetFeeForGaslessUseCase]) and the
* manual fee-token selection path ([GetFeeForTokenUseCase]) so both produce identical plans.
*/
@Suppress("LongParameterList")
internal suspend fun Raise<GetFeeError>.attachGaslessFeePlan(
resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFeeExtended: TransactionFeeExtended,
transactionData: TransactionData,
isYieldActive: Boolean,
): TransactionFeeExtended {
val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency
?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency")
val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress
?: raiseIllegalStateError("gasless fee currency must be a token")
val plan = resolveGaslessFeePlanUseCase(
userWallet = userWallet,
tokenStatus = tokenStatus,
tokenFee = feeInTokenCurrency,
isYieldActive = isYieldActive,
sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract),
).bind()
return tokenFeeExtended.copy(gaslessFeePlan = plan)
}
/**
* Computes how much of the fee token is also being spent in the main transaction body.
*
* Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to
* account for it in the required-balance check). A compiled tx or a null sent amount on the
* matching-token path are both programmer errors, so they raise loudly instead of silently
* under-accounting as ZERO.
*
* @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase].
* @param feeTokenContract the contract address of the token selected to pay the gasless fee.
* @return the sent amount when [feeTokenContract] matches the sent-token contract,
* or [BigDecimal.ZERO] when a different token is being sent.
*/
internal fun Raise<GetFeeError>.computeSendAmountInFeeToken(
transactionData: TransactionData,
feeTokenContract: String,
): BigDecimal {
// Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase).
val uncompiled = transactionData as? TransactionData.Uncompiled
?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data")
val sentTokenContract = when (val type = uncompiled.amount.type) {
is AmountType.Token -> type.token.contractAddress
is AmountType.TokenYieldSupply -> type.token.contractAddress
else -> null
}
return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) {
uncompiled.amount.value
?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token")
} else {
BigDecimal.ZERO
}
}

View file

@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.walletmanager.WalletManagersFacade
@Suppress("LongParameterList")
class GetFeeForTokenUseCase(
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val gaslessYieldRepository: GaslessYieldRepository,
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val currencyChecksRepository: CurrencyChecksRepository,
private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase,
private val isYieldWithdrawEnabled: Boolean,
) {
private val tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
suspend operator fun invoke(
@ -74,12 +80,30 @@ class GetFeeForTokenUseCase(
raiseIllegalStateError("Token currency not found for network ${token.network.id}")
}
tokenFeeCalculator.calculateTokenFee(
val isYieldActive = isYieldWithdrawEnabled &&
tokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
isYieldActive = isYieldActive,
userWallet = userWallet,
).bind()
if (isYieldActive) {
attachGaslessFeePlan(
resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase,
userWallet = userWallet,
tokenStatus = tokenCurrencyStatus,
tokenFeeExtended = tokenFeeExtended,
transactionData = transactionData,
isYieldActive = true,
)
} else {
tokenFeeExtended
}
},
catch = {
raise(GaslessError.DataError(it))

View file

@ -0,0 +1,97 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.GaslessFeePlan
import java.math.BigDecimal
import java.math.RoundingMode
class ResolveGaslessFeePlanUseCase(
private val gaslessYieldRepository: GaslessYieldRepository,
) {
suspend operator fun invoke(
userWallet: UserWallet,
tokenStatus: CryptoCurrencyStatus,
tokenFee: Fee.Ethereum.TokenCurrency,
isYieldActive: Boolean,
sendAmountInFeeToken: BigDecimal,
): Either<GetFeeError, GaslessFeePlan> = either {
val token = tokenStatus.currency as? CryptoCurrency.Token
?: raise(GaslessError.DataError(IllegalStateException("fee currency must be a token")))
val feeAmount = tokenFee.amount.value
?: raise(GaslessError.DataError(IllegalStateException("token fee amount is null")))
val totalBalance = tokenStatus.value.amount ?: BigDecimal.ZERO
val required = feeAmount + sendAmountInFeeToken
if (!isYieldActive) {
return@either if (totalBalance >= required) {
GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
} else {
raise(GaslessError.NotEnoughFunds)
}
}
val moduleBalance = gaslessYieldRepository
.getEffectiveProtocolBalance(userWallet.walletId, token) ?: BigDecimal.ZERO
// Liquid balance already on the EOA = total - what is held inside the yield module.
val liquidBalance = (totalBalance - moduleBalance).coerceAtLeast(BigDecimal.ZERO)
if (liquidBalance >= required) {
return@either GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee)
}
if (totalBalance < required) raise(GaslessError.NotEnoughFunds)
val liquidLeftForFee = (liquidBalance - sendAmountInFeeToken).coerceAtLeast(BigDecimal.ZERO)
val withdrawAmountDecimal = (feeAmount - liquidLeftForFee).coerceAtLeast(BigDecimal.ZERO)
val withdrawCallData = catch(
block = {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = Amount(
token = Token(token.symbol, token.contractAddress, token.decimals),
value = withdrawAmountDecimal,
),
)
},
catch = { error ->
when (error) {
is YieldModuleUpgradeUnavailableException,
is YieldModuleVersionIndeterminateException,
-> raise(GaslessError.ModuleUpdateUnavailable)
else -> raise(GaslessError.DataError(error))
}
},
)
val yieldModuleAddress = gaslessYieldRepository
.getYieldContractAddress(userWallet.walletId, token)
?: raise(GaslessError.DataError(IllegalStateException("yield module address is null")))
GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = token,
fee = tokenFee,
withdrawAmount = withdrawAmountDecimal
.movePointRight(token.decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger(),
withdrawCallData = withdrawCallData,
yieldModuleAddress = yieldModuleAddress,
)
}
}

View file

@ -6,12 +6,15 @@ import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
@ -19,6 +22,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -34,6 +38,7 @@ internal class TokenFeeCalculator(
private val walletManagersFacade: WalletManagersFacade,
private val gaslessTransactionRepository: GaslessTransactionRepository,
private val demoConfig: DemoConfig,
private val gaslessYieldRepository: GaslessYieldRepository,
) {
suspend fun calculateInitialFee(
@ -90,16 +95,19 @@ internal class TokenFeeCalculator(
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod", "CyclomaticComplexity")
suspend fun calculateTokenFee(
walletManager: EthereumWalletManager,
tokenForPayFeeStatus: CryptoCurrencyStatus,
nativeCurrencyStatus: CryptoCurrencyStatus,
initialFee: Fee.Ethereum,
isYieldActive: Boolean = false,
userWallet: UserWallet? = null,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
// fast finish to skip calculations if no funds in token
if (tokenForPayFeeStatus.value.amount?.isZero() == true) {
// fast finish to skip calculations if no funds in token.
// Skipped on the yield path: a zero plain balance is expected — it will be topped up from yield.
if (!isYieldActive && tokenForPayFeeStatus.value.amount?.isZero() == true) {
raise(GaslessError.NotEnoughFunds)
}
@ -120,23 +128,16 @@ internal class TokenFeeCalculator(
),
)
val feeTransferGasLimit = when (feeTransferGasLimitResult) {
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
raise(GaslessError.NotEnoughFunds)
}
}
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive)
val baseGas = gaslessTransactionRepository.getBaseGasForTransaction()
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas
val withdrawGas = if (isYieldActive) {
estimateWithdrawGasLimit(userWallet, walletManager, tokenForPayFee)
} else {
BigInteger.ZERO
}
val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + withdrawGas
val maxFeePerGas = when (initialFee) {
is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas
@ -170,7 +171,8 @@ internal class TokenFeeCalculator(
)
val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO
if (tokenBalance < feeInTokenCurrency) {
// Skipped on the yield path: ResolveGaslessFeePlanUseCase decides plain-vs-yield coverage.
if (!isYieldActive && tokenBalance < feeInTokenCurrency) {
raise(GaslessError.NotEnoughFunds)
}
@ -186,10 +188,97 @@ internal class TokenFeeCalculator(
TransactionFeeExtended(
transactionFee = TransactionFee.Single(normal = fee),
feeTokenId = tokenForPayFee.id,
// Per-call gas limits for the v2 gasless meta-tx (bound into the EIP-712 hash).
// Main = the user's transaction execution gas; withdraw = the appended yield-withdraw
// sub-call gas, present only on the yield path where a batch is built.
mainTransactionGasLimit = initialFee.gasLimit,
withdrawGasLimit = withdrawGas.takeIf { isYieldActive },
)
}
}
/**
* Resolves the fee-transfer gas limit from the on-chain estimation result.
*
* On the yield path ([isYieldActive] = true), when the estimation reverts with
* [BlockchainSdkError.Ethereum.InsufficientFundsForOperation] (expected for a zero plain balance),
* falls back to [FALLBACK_FEE_TRANSFER_GAS_LIMIT] instead of raising [GaslessError.NotEnoughFunds].
* All other failures propagate as [GaslessError.DataError] on both paths.
*/
private fun Raise<GetFeeError>.resolveFeeTransferGasLimit(
feeTransferGasLimitResult: Result<BigInteger>,
isYieldActive: Boolean,
): BigInteger {
val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) {
is Result.Success -> feeTransferGasLimitResult.data
is Result.Failure -> {
// If there is a dust on the balance, the gas limit estimation will fail with code
if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) {
val cause = feeTransferGasLimitResult.error.cause
if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) {
if (isYieldActive) {
FALLBACK_FEE_TRANSFER_GAS_LIMIT
} else {
raise(GaslessError.NotEnoughFunds)
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
} else {
raise(GaslessError.DataError(feeTransferGasLimitResult.error))
}
}
}
return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT)
}
@Suppress("SwallowedException")
private suspend fun estimateWithdrawGasLimit(
userWallet: UserWallet?,
walletManager: EthereumWalletManager,
token: CryptoCurrency.Token,
): BigInteger {
if (userWallet == null) return WITHDRAW_GAS_LIMIT
val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token)
?: return WITHDRAW_GAS_LIMIT
// The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not
// affect the gas cost. It is a token amount because the call data needs the token's contract/decimals.
val withdrawAmount = createTokenAmount(
token = token,
value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals),
)
val probeCallData = try {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
amount = withdrawAmount,
)
} catch (e: YieldModuleUpgradeUnavailableException) {
return WITHDRAW_GAS_LIMIT
} catch (e: YieldModuleVersionIndeterminateException) {
return WITHDRAW_GAS_LIMIT
}
// Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload):
// `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so
// that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it
// with the token contract address and estimate the wrong call.
val estimationAmount = Amount(
currencySymbol = token.symbol,
value = BigDecimal.ZERO,
decimals = token.decimals,
type = AmountType.Coin,
)
return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) {
is Result.Success -> result.data
is Result.Failure -> WITHDRAW_GAS_LIMIT
}
}
private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount(
token = Token(
symbol = token.symbol,
@ -217,6 +306,26 @@ internal class TokenFeeCalculator(
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10
/**
* Fallback gas for the batch yield-withdraw operation (withdraw + possible module upgrade), used when
* the on-chain probe estimation in [estimateWithdrawGasLimit] is unavailable or reverts. Overestimate-safe
* because it only inflates maxTokenFee (a cap) and the signed per-call gas limit.
*/
val WITHDRAW_GAS_LIMIT: BigInteger = BigInteger("150000")
/**
* Probe amount (in the fee token's minimal units) for the `withdraw` gas estimation. Per spec it is a
* small fixed value: large enough to simulate a real withdraw, small enough not to exceed the yield
* balance. The withdraw gas cost is effectively independent of the amount.
*/
const val PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS = 10_000L
/**
* Fallback fee-transfer gas limit used when on-chain estimation reverts due to a zero plain balance on the
* yield path. TODO: tune against testnet if needs.
*/
val FALLBACK_FEE_TRANSFER_GAS_LIMIT: BigInteger = BigInteger("100000")
/**
* Increases BigDecimal value by specified percentage.
*

View file

@ -0,0 +1,25 @@
package com.tangem.domain.transaction.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class GaslessBatchTransactionDataTest {
@Test
fun `holds transactions fee and nonce`() {
val tx = GaslessTransactionData.Transaction(
to = "0xabc", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(1),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xdef", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(2),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(transactions = listOf(tx, withdraw), fee = fee, nonce = BigInteger.ZERO)
assertThat(batch.transactions).hasSize(2)
assertThat(batch.transactions[1]).isEqualTo(withdraw)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.domain.transaction.usecase.gasless
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.transaction.error.GetFeeError
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Unit tests for [computeSendAmountInFeeToken].
*
* Cases:
* (a) Different token ZERO (fee token sent token).
* (b) Same token via AmountType.Token the actual sent amount.
* (c) Same token via AmountType.TokenYieldSupply the actual sent amount.
* (d) Same token but amount.value == null raises (loud error, never silent ZERO).
* (e) Compiled tx raises (gasless token-fee requires uncompiled data).
*/
class ComputeSendAmountInFeeTokenTest {
private val feeContract = "0xUSDC"
private val otherContract = "0xDAI"
private val sentAmount = BigDecimal("50.0")
private fun makeToken(contract: String) = Token(
name = "TestToken",
symbol = "TST",
contractAddress = contract,
decimals = 6,
)
private fun uncompiledWith(type: AmountType, value: BigDecimal?) = TransactionData.Uncompiled(
amount = Amount(
currencySymbol = "TST",
value = value,
maxValue = null,
decimals = 6,
type = type,
),
sourceAddress = "0xSrc",
destinationAddress = "0xDst",
fee = null,
)
// (a) Sent token is different from fee token → ZERO
@Test
fun `returns ZERO when sent token differs from fee token`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(otherContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(BigDecimal.ZERO, result.getOrNull())
}
// (b) AmountType.Token — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType Token matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (b) Case-insensitive contract address match
@Test
fun `contract address comparison is case-insensitive`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract.uppercase())),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract.lowercase())
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (c) AmountType.TokenYieldSupply — same contract as fee token → returns the sent amount
@Test
fun `returns sent amount when AmountType TokenYieldSupply matches fee token contract`() {
val tx = uncompiledWith(
type = AmountType.TokenYieldSupply(
token = makeToken(feeContract),
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
),
value = sentAmount,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isRight())
assertEquals(sentAmount, result.getOrNull())
}
// (d) Same token but amount.value == null → raises (never silently under-accounts as ZERO)
@Test
fun `raises when same token is sent but amount value is null`() {
val tx = uncompiledWith(
type = AmountType.Token(makeToken(feeContract)),
value = null,
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(tx, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) when sent amount is null")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
// (e) Compiled tx → raises (gasless token-fee requires uncompiled data)
@Test
fun `raises when transactionData is Compiled`() {
val compiled = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(byteArrayOf(0x01, 0x02)),
)
val result = either<GetFeeError, BigDecimal> {
computeSendAmountInFeeToken(compiled, feeContract)
}
assertTrue(result.isLeft(), "Expected Left (error) for compiled tx")
assertTrue(
result.leftOrNull() is GetFeeError.DataError,
"Expected GetFeeError.DataError wrapping IllegalStateException",
)
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.getDestinationAddress] resolves the on-chain
* `to` of the user's main gasless sub-call.
*
* Regression guard: a yield-supply send must target the user's yield MODULE (the contract that
* runs `send(token, dest, amount)`), not the transfer recipient. Targeting the recipient reverts the whole
* batch with GAS_ESTIMATION_FAILED / require(false).
*/
internal class CreateAndSendGaslessDestinationAddressTest {
private val module = "0xmodule"
private val recipient = "0xrecipient"
private val tokenContract = "0xtokencontract"
private fun uncompiled(
destinationAddress: String,
extras: EthereumTransactionExtras?,
contractAddress: String?,
) = TransactionData.Uncompiled(
amount = mockk(relaxed = true),
fee = null,
sourceAddress = "0xsource",
destinationAddress = destinationAddress,
extras = extras,
contractAddress = contractAddress,
)
@Test
fun `GIVEN yield-supply send WHEN getDestinationAddress THEN returns module not recipient`() {
// Arrange — destinationAddress is patched to the yield module; the recipient lives inside the callData
val yieldCallData = EthereumYieldSupplySendCallData(
tokenContractAddress = tokenContract,
destinationAddress = recipient,
amount = mockk(relaxed = true),
)
val txData = uncompiled(
destinationAddress = module,
extras = EthereumTransactionExtras(callData = yieldCallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(module)
}
@Test
fun `GIVEN ERC20 transfer WHEN getDestinationAddress THEN returns token contract`() {
// Arrange — a non-yield callData; `to` must be the token contract, not the recipient
val erc20CallData = object : SmartContractCallData {
override val methodId = "0xa9059cbb"
override val data = byteArrayOf(0x01)
override fun validate(blockchain: Blockchain) = true
}
val txData = uncompiled(
destinationAddress = recipient,
extras = EthereumTransactionExtras(callData = erc20CallData),
contractAddress = tokenContract,
)
// Act
val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
// Assert
assertThat(to).isEqualTo(tokenContract)
}
@Test
fun `GIVEN non-yield tx without contract address WHEN getDestinationAddress THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = recipient,
extras = null,
contractAddress = null,
)
// Act & Assert
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData)
}
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase.GaslessPayload
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigInteger
/**
* Unit tests for [CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload].
* Pure function no coroutines or SDK side-effects.
*/
internal class CreateAndSendGaslessPayloadTest {
// ─── Common fixtures ─────────────────────────────────────────────────────────
private val mainTx = GaslessTransactionData.Transaction(
to = "0xmain",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x01, 0x02),
)
private val withdrawGasLimit = BigInteger.valueOf(150_000)
private val feeObj = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(21_000),
feeReceiver = "0xrecv",
)
private val nonce = BigInteger.valueOf(42)
// Minimal SmartContractCallData fake — only `data` is consumed by the SUT.
private val fakeWithdrawCallData = object : SmartContractCallData {
override val methodId: String = "0xfakeid"
override val data: ByteArray = byteArrayOf(0x12, 0x34)
override fun validate(blockchain: com.tangem.blockchain.common.Blockchain) = true
}
private val fakeToken: CryptoCurrency.Token = mockk(relaxed = true)
private val fakeTokenFee: Fee.Ethereum.TokenCurrency = mockk(relaxed = true)
private val fakeNativeFee: Fee = mockk(relaxed = true)
// ─── Case 1: TokenPayWithYieldWithdraw → GaslessPayload.Batch ────────────────
@Test
fun `TokenPayWithYieldWithdraw plan returns Batch with correct structure`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = withdrawGasLimit,
)
assertThat(result).isInstanceOf(GaslessPayload.Batch::class.java)
val batch = (result as GaslessPayload.Batch).data
// transactions list has exactly 2 entries
assertThat(batch.transactions).hasSize(2)
// index 0 is the unchanged main transaction
assertThat(batch.transactions[0]).isEqualTo(mainTx)
// index 1 is the yield-withdraw transaction
val withdrawTx = batch.transactions[1]
assertThat(withdrawTx.to).isEqualTo(plan.yieldModuleAddress)
assertThat(withdrawTx.value).isEqualTo(BigInteger.ZERO)
assertThat(withdrawTx.gasLimit).isEqualTo(withdrawGasLimit)
assertThat(withdrawTx.data).isEqualTo(fakeWithdrawCallData.data)
// fee and nonce are carried through
assertThat(batch.fee).isEqualTo(feeObj)
assertThat(batch.nonce).isEqualTo(nonce)
}
// ─── Case 2: TokenPay → GaslessPayload.Single ────────────────────────────────
@Test
fun `TokenPay plan returns Single wrapping mainTx feeObj and nonce`() {
val plan = GaslessFeePlan.TokenPay(feeToken = fakeToken, fee = fakeTokenFee)
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 3: null plan → GaslessPayload.Single (same as TokenPay) ───────────
@Test
fun `null plan returns Single wrapping mainTx feeObj and nonce`() {
val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = null,
withdrawGasLimit = null,
)
assertThat(result).isInstanceOf(GaslessPayload.Single::class.java)
val single = (result as GaslessPayload.Single).data
assertThat(single.transaction).isEqualTo(mainTx)
assertThat(single.fee).isEqualTo(feeObj)
assertThat(single.nonce).isEqualTo(nonce)
}
// ─── Case 4: NativePay → throws IllegalStateException ───────────────────────
@Test
fun `NativePay plan throws IllegalStateException`() {
val plan = GaslessFeePlan.NativePay(fee = fakeNativeFee)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
// ─── Case 5: yield-withdraw plan without a withdraw gas limit → throws ────────
@Test
fun `TokenPayWithYieldWithdraw plan without withdrawGasLimit throws IllegalStateException`() {
val plan = GaslessFeePlan.TokenPayWithYieldWithdraw(
feeToken = fakeToken,
fee = fakeTokenFee,
withdrawAmount = BigInteger.valueOf(7_000_001),
withdrawCallData = fakeWithdrawCallData,
yieldModuleAddress = "0xmodule",
)
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload(
mainTx = mainTx,
feeObj = feeObj,
nonce = nonce,
plan = plan,
withdrawGasLimit = null,
)
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderBatchTest {
@Test
fun `buildBatch emits GaslessBatchTransaction primary type with transactions array`() {
val tx = GaslessTransactionData.Transaction(
to = "0xaaa", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(0x12),
)
val withdraw = GaslessTransactionData.Transaction(
to = "0xbbb", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(0x34),
)
val fee = GaslessTransactionData.Fee(
feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv",
)
val batch = GaslessBatchTransactionData(listOf(tx, withdraw), fee, BigInteger.ZERO)
val json = JSONObject(Eip712TypedDataBuilder.buildBatch(batch, chainId = 1, verifyingContract = "0xuser"))
assertThat(json.getString("primaryType")).isEqualTo("GaslessBatchTransaction")
val message = json.getJSONObject("message")
assertThat(message.getJSONArray("transactions").length()).isEqualTo(2)
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("to")).isEqualTo("0xbbb")
// v2: each sub-call carries its per-call gasLimit in the message
assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("gasLimit")).isEqualTo("150000")
val types = json.getJSONObject("types").getJSONArray("GaslessBatchTransaction")
assertThat(types.getJSONObject(0).getString("type")).isEqualTo("Transaction[]")
// v2: the Transaction struct adds gasLimit between value and data
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.json.JSONObject
import org.junit.jupiter.api.Test
import java.math.BigInteger
internal class Eip712TypedDataBuilderTest {
@Test
fun `build emits GaslessTransaction primary type with per-call gasLimit in type and message`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act
val json = JSONObject(
Eip712TypedDataBuilder.build(gaslessTransaction, chainId = 137, verifyingContract = "0xuser"),
)
// Assert
assertThat(json.getString("primaryType")).isEqualTo("GaslessTransaction")
// v2: the single transaction carries its per-call gasLimit in the message
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.getString("gasLimit")).isEqualTo("120000")
// v2: the Transaction struct adds gasLimit between value and data (order defines the EIP-712 typehash)
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder()
// Domain is unchanged between v1/v2; verifyingContract is the user's EOA address
val domain = json.getJSONObject("domain")
assertThat(domain.getString("name")).isEqualTo("Tangem7702GaslessExecutor")
assertThat(domain.getString("version")).isEqualTo("1")
assertThat(domain.getString("verifyingContract")).isEqualTo("0xuser")
}
@Test
fun `build with includeGasLimit false omits gasLimit reproducing the v1 typehash`() {
// Arrange
val gaslessTransaction = GaslessTransactionData(
transaction = GaslessTransactionData.Transaction(
to = "0xaaa",
value = BigInteger.ZERO,
gasLimit = BigInteger.valueOf(120_000),
data = byteArrayOf(0x12, 0x34),
),
fee = GaslessTransactionData.Fee(
feeToken = "0xtoken",
maxTokenFee = BigInteger.TEN,
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(60_000),
baseGas = BigInteger.valueOf(60_000),
feeReceiver = "0xrecv",
),
nonce = BigInteger.ZERO,
)
// Act — v1 mode (feature flag off)
val json = JSONObject(
Eip712TypedDataBuilder.build(
gaslessTransaction = gaslessTransaction,
chainId = 137,
verifyingContract = "0xuser",
includeGasLimit = false,
),
)
// Assert: the Transaction struct is the legacy {to, value, data} — gasLimit drives the typehash, so its
// absence reproduces exactly the v1 hash the current develop signs.
val txType = json.getJSONObject("types").getJSONArray("Transaction")
val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") }
assertThat(txTypeFields).containsExactly("to", "value", "data").inOrder()
// and the message carries no gasLimit
val txMessage = json.getJSONObject("message").getJSONObject("transaction")
assertThat(txMessage.has("gasLimit")).isFalse()
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase.Companion.isEligibleFeeToken
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetAvailableFeeTokensUseCaseTest {
@ParameterizedTest
@ProvideTestModels
fun isEligible(model: EligibilityModel) {
// Arrange
val status = createStatus(model.yieldSupplyStatus)
// Act
val actual = isEligibleFeeToken(status, isYieldWithdrawEnabled = model.isYieldWithdrawEnabled)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
// Plain token (no yield status) is always eligible, regardless of the toggle.
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = false, expected = true),
EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = true, expected = true),
// Active yield: eligible only when gasless v2 (yield withdraw) is enabled.
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = true),
EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
// Inactive yield status: excluded either way (no module to withdraw from).
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = false),
EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false),
)
internal data class EligibilityModel(
val yieldSupplyStatus: YieldSupplyStatus?,
val isYieldWithdrawEnabled: Boolean,
val expected: Boolean,
)
private fun createStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val status = mockk<CryptoCurrencyStatus>()
every { status.value.yieldSupplyStatus } returns yieldSupplyStatus
return status
}
private companion object {
val ACTIVE_YIELD = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val INACTIVE_YIELD = ACTIVE_YIELD.copy(isActive = false)
}
}

View file

@ -0,0 +1,425 @@
package com.tangem.domain.transaction.usecase.gasless
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.GaslessFeePlan
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* Unit tests for [ResolveGaslessFeePlanUseCase].
* Covers every branch of the gasless fee decision tree.
*/
internal class ResolveGaslessFeePlanUseCaseTest {
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var useCase: ResolveGaslessFeePlanUseCase
private val mockUserWalletId: UserWalletId = mockk(relaxed = true)
private val mockUserWallet: UserWallet = mockk<UserWallet.Hot>().also {
every { it.walletId } returns mockUserWalletId
}
@BeforeEach
fun setup() {
gaslessYieldRepository = mockk()
useCase = ResolveGaslessFeePlanUseCase(gaslessYieldRepository)
}
// ─── Case 1: plain balance >= required → TokenPay ──────────────────────────
@Test
fun `plain balance covers fee returns TokenPay`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull()
assertThat(plan).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
assertThat((plan as GaslessFeePlan.TokenPay).fee).isEqualTo(tokenFee)
}
@Test
fun `plain balance equals required returns TokenPay`() = runTest {
val amount = BigDecimal("5")
val tokenStatus = tokenStatus(plainBalance = amount, decimals = 6)
val tokenFee = tokenFee(feeAmount = amount, decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Case 2: yield-active with no liquid → the whole fee is withdrawn from the module ──
@Test
fun `yield active with no liquid withdraws the whole fee`() = runTest {
val decimals = 6
// value.amount is effectiveBalance = liquid(EOA) + effectiveProtocolBalance. Here total == module
// balance (20), so liquid is 0 and the entire fee must be withdrawn from the module — the plan must
// not short-circuit to TokenPay.
// withdraw == feeAmount, CEILING-rounded: 10000000.5 → 10000001 (floor would give 10000000).
val feeAmount = BigDecimal("10.0000005")
val moduleBalance = BigDecimal("20")
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val floorAmount = feeAmount.movePointRight(decimals).toBigInteger() // 10000000
assertThat(expectedWithdrawAmount).isGreaterThan(floorAmount)
// value.amount == module balance → liquid is 0, so the fee cannot be paid from the EOA (no TokenPay).
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
// Must be 10000001 (CEILING of the fee), not the module balance and not floor.
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(10_000_001))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2b: send amount counts toward sufficiency but NOT toward the withdraw ────────────
@Test
fun `yield active withdraw covers only the fee not the send amount`() = runTest {
val decimals = 6
// The main module.send tx moves the send amount from the module itself, so the fee-withdraw must
// cover ONLY the fee. Including the send amount would withdraw it twice and overdraw the module.
val feeAmount = BigDecimal("3.0")
val sendAmountInFeeToken = BigDecimal("1.5")
val moduleBalance = BigDecimal("5.0") // covers required = fee(3.0) + send(1.5) = 4.5 ✓
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger() // 3000000 — the FEE only, NOT 4.5
val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(
userWalletId = mockUserWalletId,
cryptoCurrency = any(),
amount = any(),
)
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmountInFeeToken,
)
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(3_000_000))
assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule")
assertThat(plan.withdrawCallData).isEqualTo(mockCallData)
}
// ─── Case 2c: module cannot cover send + fee → NotEnoughFunds ──────────────
@Test
fun `yield active module cannot cover send plus fee returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("4"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("3"), decimals = 6)
// required = fee(3) + send(1.5) = 4.5, but the module holds only 4.0
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("4.0")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("1.5"),
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 3: plain insufficient, isYieldActive=false → NotEnoughFunds ──────
@Test
fun `plain insufficient yield inactive returns NotEnoughFunds`() = runTest {
val tokenStatus = tokenStatus(plainBalance = BigDecimal("1"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = false,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 4: YieldModuleUpgradeUnavailableException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws UpgradeUnavailableException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleUpgradeUnavailableException("0xold")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 5: plain + yield < required → NotEnoughFunds ─────────────────────
@Test
fun `plain plus yield insufficient returns NotEnoughFunds`() = runTest {
// total(6) = liquid(1) + module(5) < fee(10) → not enough funds anywhere.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("6"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("10"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("5") // liquid 1 + module 5 = 6 < 10
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java)
}
// ─── Case 6: YieldModuleVersionIndeterminateException → ModuleUpdateUnavailable
@Test
fun `createPartialWithdrawCallData throws VersionIndeterminateException returns ModuleUpdateUnavailable`() = runTest {
// total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("10")
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any())
} throws YieldModuleVersionIndeterminateException("rpc error")
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal.ZERO,
)
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java)
}
// ─── Case 7: liquid EOA balance covers most of send+fee, protocol alone does not ──────────────
@Test
fun `GIVEN liquid covers send but protocol alone does not WHEN yield active THEN TokenPayWithYieldWithdraw`() =
runTest {
// value.amount is effectiveBalance (liquid EOA + effectiveProtocolBalance). The user sends 3.00 of
// 3.585624 total. The yield module (effectiveProtocolBalance) holds only 0.6, the rest (2.985624)
// is liquid on the EOA. required = send(3.00) + fee(0.05) = 3.05 < total(3.585624), so funds ARE
// sufficient. The old check compared the module balance (0.6) against required and wrongly raised
// NotEnoughFunds.
val decimals = 6
val totalBalance = BigDecimal("3.585624")
val moduleBalance = BigDecimal("0.6")
val feeAmount = BigDecimal("0.05")
val sendAmount = BigDecimal("3.00")
// module.send consumes EOA liquid first, leaving 0 for the fee, so the whole fee must be withdrawn.
val expectedWithdrawAmount = feeAmount
.movePointRight(decimals)
.setScale(0, RoundingMode.CEILING)
.toBigInteger()
val tokenStatus = tokenStatus(plainBalance = totalBalance, decimals = decimals)
val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals)
val mockCallData = mockk<SmartContractCallData>(relaxed = true)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns moduleBalance
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockCallData
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xmodule"
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = sendAmount,
)
// Assert
assertThat(result.isRight()).isTrue()
val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw
assertThat(plan).isNotNull()
assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount)
}
// ─── Case 8: liquid EOA balance alone covers send + fee → no withdraw needed ───────────────────
@Test
fun `GIVEN liquid covers send plus fee WHEN yield active THEN TokenPay without withdraw`() = runTest {
// Arrange — liquid = total(10) - module(2) = 8, which already covers required = send(3) + fee(1) = 4.
// The EOA holds enough after the main send to settle the fee, so no yield withdraw is needed.
val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6)
val tokenFee = tokenFee(feeAmount = BigDecimal("1"), decimals = 6)
coEvery {
gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any())
} returns BigDecimal("2")
// Act
val result = useCase(
userWallet = mockUserWallet,
tokenStatus = tokenStatus,
tokenFee = tokenFee,
isYieldActive = true,
sendAmountInFeeToken = BigDecimal("3"),
)
// Assert
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java)
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private fun tokenStatus(
plainBalance: BigDecimal = BigDecimal("100"),
decimals: Int = 6,
): CryptoCurrencyStatus {
val token = mockk<CryptoCurrency.Token>(relaxed = true)
every { token.symbol } returns "USDC"
every { token.contractAddress } returns "0xUSDC"
every { token.decimals } returns decimals
val status = mockk<CryptoCurrencyStatus>()
every { status.currency } returns token
every { status.value.amount } returns plainBalance
return status
}
private fun tokenFee(feeAmount: BigDecimal, decimals: Int = 6): Fee.Ethereum.TokenCurrency {
val blockchainToken = Token(symbol = "USDC", contractAddress = "0xUSDC", decimals = decimals)
val amount = Amount(token = blockchainToken, value = feeAmount)
return Fee.Ethereum.TokenCurrency(
amount = amount,
gasLimit = BigInteger("100000"),
coinPriceInToken = BigInteger("2000000000"),
feeTransferGasLimit = BigInteger("60000"),
baseGas = BigInteger("21000"),
)
}
}

View file

@ -14,7 +14,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.models.yield.supply.YieldSupplyStatus
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
@ -36,6 +39,7 @@ class TokenFeeCalculatorTest {
private lateinit var walletManagersFacade: WalletManagersFacade
private lateinit var gaslessTransactionRepository: GaslessTransactionRepository
private lateinit var gaslessYieldRepository: GaslessYieldRepository
private lateinit var demoConfig: DemoConfig
private lateinit var tokenFeeCalculator: TokenFeeCalculator
@ -49,12 +53,14 @@ class TokenFeeCalculatorTest {
fun setup() {
walletManagersFacade = mockk()
gaslessTransactionRepository = mockk()
gaslessYieldRepository = mockk()
demoConfig = mockk()
tokenFeeCalculator = TokenFeeCalculator(
walletManagersFacade = walletManagersFacade,
gaslessTransactionRepository = gaslessTransactionRepository,
demoConfig = demoConfig,
gaslessYieldRepository = gaslessYieldRepository,
)
mockWalletManager = mockk()
@ -215,6 +221,9 @@ class TokenFeeCalculatorTest {
assertNotNull(feeExtended)
assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId)
assertTrue(feeExtended.transactionFee is TransactionFee.Single)
// main-tx per-call gas = initialFee.gasLimit; no withdraw on the non-yield path
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertNull(feeExtended.withdrawGasLimit)
}
}
@ -413,6 +422,283 @@ class TokenFeeCalculatorTest {
}
}
// ===== Yield-path Tests =====
/**
* With active yield, a token whose plain balance is small (not enough to pay the fee on its own) must NOT
* raise NotEnoughFunds the resolver decides coverage. The gas limit must include the extra withdraw gas.
*
* Here `userWallet` is not passed (null), so the withdraw gas estimation is skipped and the
* deterministic fallback [WITHDRAW_GAS_LIMIT] is used.
*
* Expected gasLimit breakdown (matching companion constants):
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total = 337_000
*/
@Test
fun `calculateTokenFee with active yield but no wallet falls back to WITHDRAW_GAS_LIMIT`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"), // yield covers the rest
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // tiny plain balance — insufficient on its own
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with small plain balance")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 = 337_000
assertEquals(BigInteger("337000"), fee.gasLimit, "gasLimit must include WITHDRAW_GAS_LIMIT (150000)")
// feeTransferGasLimit stored in the fee object = 66_000
assertEquals(BigInteger("66000"), fee.feeTransferGasLimit, "feeTransferGasLimit = 60000 * 1.10")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = WITHDRAW_GAS_LIMIT
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
}
/**
* With active yield, when getGasLimit reverts due to zero plain balance
* (BlockchainSdkError.Ethereum.InsufficientFundsForOperation wrapped in WrappedThrowable),
* calculateTokenFee must use the deterministic FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) instead of raising.
*
* Expected breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 100_000 * 1.10 = 110_000 (FALLBACK_FEE_TRANSFER_GAS_LIMIT * 1.10)
* baseGas = 21_000
* WITHDRAW_GAS_LIMIT = 150_000
* total gasLimit = 381_000
*/
@Test
fun `calculateTokenFee with active yield uses fallback gas when transfer estimation reverts with insufficient funds`() =
runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
// Zero plain balance — exactly the condition that causes estimation revert
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
// Simulate on-chain estimation reverting with InsufficientFundsForOperation
val insufficientFundsException =
BlockchainSdkError.Ethereum.InsufficientFundsForOperation("insufficient funds for gas")
val wrappedError = BlockchainSdkError.WrappedThrowable(insufficientFundsException)
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Failure(wrappedError)
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
)
// Then
assertTrue(result.isRight(), "Expected success with fallback gas on yield path")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// feeTransferGasLimit = FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) * 1.10 = 110_000
assertEquals(
BigInteger("110000"),
fee.feeTransferGasLimit,
"feeTransferGasLimit must use fallback (100000 * 1.10 = 110000)",
)
// gasLimit = 100_000 + 110_000 + 21_000 + 150_000 = 381_000
assertEquals(
BigInteger("381000"),
fee.gasLimit,
"gasLimit must include WITHDRAW_GAS_LIMIT (150000)",
)
}
}
/**
* Confirms that the non-yield path (isYieldActive = false, default) is unchanged:
* a token with insufficient plain balance still raises NotEnoughFunds.
*/
@Test
fun `calculateTokenFee without yield still raises NotEnoughFunds on insufficient balance`() = runTest {
// Given
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"), // very small — insufficient
fiatRate = BigDecimal("1"),
)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// When — default isYieldActive = false
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
)
// Then
assertTrue(result.isLeft(), "Non-yield path must still raise NotEnoughFunds for insufficient balance")
result.onLeft { error ->
assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds)
}
}
/**
* With active yield AND a wallet, the withdraw gas limit is estimated on-chain via a probe
* `withdraw(yieldToken, 10000)` against the yield module. The estimated value (here 200_000) flows into
* BOTH the maxTokenFee cap and the signed per-call withdraw gas limit not the hardcoded fallback.
*
* Expected gasLimit breakdown:
* initialFee.gasLimit = 100_000
* feeTransferGasLimit = 60_000 * 1.10 = 66_000
* baseGas = 21_000
* estimated withdraw = 200_000
* total = 387_000
*/
@Test
fun `calculateTokenFee with active yield and wallet estimates withdraw gas on-chain`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee() // gasLimit = 100_000
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
// fee-transfer estimation (to the fee receiver) vs. withdraw estimation (to the yield module)
coEvery {
mockWalletManager.getGasLimit(any(), "0xFeeReceiver", any())
} returns Result.Success(BigInteger("60000"))
coEvery {
mockWalletManager.getGasLimit(any(), "0xModule", any())
} returns Result.Success(BigInteger("200000"))
coEvery {
gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any())
} returns "0xModule"
coEvery {
gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any())
} returns mockk<SmartContractCallData>(relaxed = true)
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation")
result.onRight { feeExtended ->
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
// gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000
assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas")
// v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000
assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit)
assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit)
}
coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) }
coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) }
}
/**
* When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation
* is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] even though a wallet is provided.
*/
@Test
fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest {
// Given
val activeYieldStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal("100"),
)
val tokenStatus = createMockTokenStatus(
balance = BigDecimal("0.001"),
fiatRate = BigDecimal("1"),
).withYieldSupplyStatus(activeYieldStatus)
val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000"))
val initialFee = createMockEIP1559Fee()
coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000"))
coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver"
every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000")
coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null
// When
val result = tokenFeeCalculator.calculateTokenFee(
walletManager = mockWalletManager,
tokenForPayFeeStatus = tokenStatus,
nativeCurrencyStatus = nativeStatus,
initialFee = initialFee,
isYieldActive = true,
userWallet = mockUserWallet,
)
// Then
assertTrue(result.isRight())
result.onRight { feeExtended ->
// gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000
val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency
assertEquals(BigInteger("337000"), fee.gasLimit)
assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit)
}
// withdraw estimation must NOT be attempted without a module address
coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) }
}
// ===== Helper Methods =====
private fun createMockTransactionFee(): TransactionFee {
@ -455,6 +741,21 @@ class TokenFeeCalculatorTest {
return status
}
/**
* Returns a copy of this [CryptoCurrencyStatus] mock with [yieldSupplyStatus] overridden.
* Since [CryptoCurrencyStatus] is a mockk, we create a new mock that delegates everything and
* overrides only [yieldSupplyStatus].
*/
private fun CryptoCurrencyStatus.withYieldSupplyStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus {
val original = this
val newStatus = mockk<CryptoCurrencyStatus>()
every { newStatus.currency } returns original.currency
every { newStatus.value.amount } returns original.value.amount
every { newStatus.value.fiatRate } returns original.value.fiatRate
every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus
return newStatus
}
private fun createMockNativeCurrencyStatus(
fiatRate: BigDecimal? = BigDecimal("2000"),
decimals: Int = 18,
@ -471,4 +772,4 @@ class TokenFeeCalculatorTest {
return status
}
}
}

View file

@ -63,6 +63,17 @@ sealed interface OnChainTx : TxHistoryInfo {
*/
fun TxInfo.identityKey(): String = "$txHash|$type"
/**
* Hash of the matched on-chain leg, used to open the row in a block explorer; `null` when there is no
* blockchain tx to link to an [ExpressTx] whose on-chain leg has not matched yet. The express `txId`
* must never stand in here: it is not an on-chain hash and would build a broken explorer URL.
*/
inline val TxHistoryInfo.explorerHash: String?
get() = when (this) {
is OnChainTx.BSDK -> txInfo.txHash
is ExpressTx -> matchHash
}
/**
* A history row backed by an express operation. It is a thin wrapper over the standalone express
* model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns:

View file

@ -1,4 +1,4 @@
package com.tangem.domain.wallets.models
package com.tangem.domain.wallets.models.errors
sealed class GetUserWalletError {

View file

@ -13,6 +13,8 @@ sealed class Settings(
class ButtonManageTokens : Settings(event = "Button - Manage Tokens")
class ButtonOpenChat : Settings(event = "Button - Open Chat")
class ColdWalletAdded(
source: AnalyticsParam.ScreensSources?,
) : Settings(

View file

@ -4,7 +4,7 @@ import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.models.errors.GetUserWalletError
/**
* Use case for getting selected wallet.

View file

@ -4,7 +4,7 @@ import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull

View file

@ -10,7 +10,7 @@ import com.tangem.domain.common.wallets.requireUserWalletsSync
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transformLatest

View file

@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.GaslessYieldRepository
import java.math.BigDecimal
interface YieldSupplyTransactionRepository {
interface YieldSupplyTransactionRepository : GaslessYieldRepository {
suspend fun createEnterTransactions(
userWalletId: UserWalletId,
@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository {
fee: Fee?,
): TransactionData.Uncompiled
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/**
* Checks the version status of the user's yield-module contract and wraps [callData] with an
* upgrade transaction if the deployed version is out of date.
@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository {
network: Network,
callData: SmartContractCallData,
): SmartContractCallData
/** Returns the on-chain version status of the user's yield module for [network]. */
suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus
}

View file

@ -0,0 +1,406 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.RoundingMode
@OptIn(ExperimentalCoroutinesApi::class)
internal class YieldSupplyGetMaxFeeUseCaseTest {
private val yieldSupplyRepository: YieldSupplyRepository = mockk()
private val quotesRepository: QuotesRepository = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val useCase = YieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository = yieldSupplyRepository,
quotesRepository = quotesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() {
clearMocks(yieldSupplyRepository, quotesRepository, singleAccountListSupplier)
}
@Test
fun `GIVEN cached market token WHEN invoke THEN converts and HALF_UP-rounds the fee to token and fiat`() =
runTest {
// Arrange — values chosen to pin the formula AND the rounding mode with literal expectations:
// fiatMaxFee = maxFeeNative(0.0002) * nativeFiatRate(1000) = 0.2
// tokenMaxFee = 0.2 / tokenFiatRate(3) = 0.066666… → 0.066667 at 6 decimals (HALF_UP; HALF_DOWN = 0.066666)
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("3"))
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, fiatRate = BigDecimal("1000"))
coEvery { yieldSupplyRepository.getCachedMarkets() } returns listOf(
createMarketToken(token = token, maxFeeNative = BigDecimal("0.0002")),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert — literal expectations, not a mirror of the production expression
assertThat(result).isEqualTo(
Either.Right(
YieldSupplyMaxFee(
nativeMaxFee = BigDecimal("0.0002"),
tokenMaxFee = BigDecimal("0.066667"),
fiatMaxFee = BigDecimal("0.2"),
),
),
)
coVerify(exactly = 0) { yieldSupplyRepository.getTokenStatus(any()) }
}
@Test
fun `GIVEN no matching cached token WHEN invoke THEN falls back to fetching token status`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
val nativeFiatRate = BigDecimal("2000.00")
val maxFeeNative = BigDecimal("0.005")
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, nativeFiatRate)
coEvery { yieldSupplyRepository.getCachedMarkets() } returns emptyList()
coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken(
token = token,
maxFeeNative = maxFeeNative,
)
val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate)
val expected = YieldSupplyMaxFee(
nativeMaxFee = maxFeeNative,
tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP),
fiatMaxFee = fiatMaxFee.stripTrailingZeros(),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertThat(result).isEqualTo(Either.Right(expected))
coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) }
}
@Test
fun `GIVEN null cached markets WHEN invoke THEN falls back to fetching token status`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
val nativeFiatRate = BigDecimal("2000.00")
val maxFeeNative = BigDecimal("0.005")
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, nativeFiatRate)
coEvery { yieldSupplyRepository.getCachedMarkets() } returns null
coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken(
token = token,
maxFeeNative = maxFeeNative,
)
val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate)
val expected = YieldSupplyMaxFee(
nativeMaxFee = maxFeeNative,
tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP),
fiatMaxFee = fiatMaxFee.stripTrailingZeros(),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertThat(result).isEqualTo(Either.Right(expected))
coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) }
}
@Test
fun `GIVEN currency is not a token WHEN invoke THEN returns error`() = runTest {
// Arrange
val coinStatus = createCoinStatus(createCoin(rawNetworkId = NETWORK_ID, decimals = 18))
// Act
val result = useCase(userWalletId, coinStatus)
// Assert
assertLeftWithMessage(result, "CryptoCurrency must be token for max fee calculation")
}
@Test
fun `GIVEN token fiat rate missing WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = null)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Fiat rate is missing")
}
@Test
fun `GIVEN token fiat rate non-positive WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal.ZERO)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Fiat rate for token must be > 0")
}
@Test
fun `GIVEN account status list missing WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) } returns null
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftStartingWith(result, "Account status list is missing")
}
@Test
fun `GIVEN native coin not found in account list WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
coEvery {
singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId)
} returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(token))
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftStartingWith(result, "Unable to find coin for network ID")
}
@Test
fun `GIVEN native quotes unavailable WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns null
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Quotes for native coin are unavailable")
}
@Test
fun `GIVEN empty native quotes list WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns emptySet()
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Empty quotes list for native coin")
}
@Test
fun `GIVEN native quote has no fiat rate WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns setOf(QuoteStatus(rawCurrencyId = nativeCoin.id.rawCurrencyId!!))
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Native fiat rate is missing")
}
@Test
fun `GIVEN native fiat rate non-positive WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, fiatRate = BigDecimal.ZERO)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Native fiat rate must be > 0")
}
// region Helpers
private fun stubAccountList(token: CryptoCurrency.Token, nativeCoin: CryptoCurrency.Coin) {
coEvery {
singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId)
} returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(nativeCoin, token))
}
private fun stubNativeQuote(nativeCoin: CryptoCurrency.Coin, fiatRate: BigDecimal) {
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns setOf(
QuoteStatus(
rawCurrencyId = nativeCoin.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = fiatRate,
fiatRateUSD = fiatRate,
priceChange = BigDecimal.ZERO,
),
),
)
}
private fun assertLeftWithMessage(result: Either<Throwable, YieldSupplyMaxFee>, message: String) {
assertThat(result.isLeft()).isTrue()
assertThat((result as Either.Left).value.message).isEqualTo(message)
}
private fun assertLeftStartingWith(result: Either<Throwable, YieldSupplyMaxFee>, prefix: String) {
assertThat(result.isLeft()).isTrue()
assertThat((result as Either.Left).value.message).startsWith(prefix)
}
private fun createMarketToken(token: CryptoCurrency.Token, maxFeeNative: BigDecimal): YieldMarketToken =
YieldMarketToken(
tokenAddress = token.contractAddress,
chainId = 1,
apy = BigDecimal.ZERO,
isActive = true,
maxFeeNative = maxFeeNative,
maxFeeUSD = BigDecimal.ZERO,
backendId = token.network.rawId,
)
private fun createToken(rawNetworkId: String, decimals: Int): CryptoCurrency.Token {
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = createNetwork(rawNetworkId),
name = "TEST_TOKEN",
symbol = "TTK",
decimals = decimals,
iconUrl = null,
isCustom = false,
contractAddress = "0xToken",
)
}
private fun createCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = createNetwork(rawNetworkId),
name = "TEST_COIN",
symbol = "TCN",
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}
private fun createNetwork(rawNetworkId: String): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(value = rawNetworkId, derivationPath = derivationPath),
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private fun createTokenStatus(token: CryptoCurrency.Token, fiatRate: BigDecimal?): CryptoCurrencyStatus =
CryptoCurrencyStatus(currency = token, value = customValue(fiatRate))
private fun createCoinStatus(coin: CryptoCurrency.Coin): CryptoCurrencyStatus =
CryptoCurrencyStatus(currency = coin, value = customValue(BigDecimal.ONE))
private fun customValue(fiatRate: BigDecimal?): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom(
amount = BigDecimal.ZERO,
fiatAmount = BigDecimal.ZERO,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = "0x0000000000000000000000000000000000000000",
type = NetworkAddress.Address.Type.Primary,
),
),
sources = CryptoCurrencyStatus.Sources(),
)
// endregion
private companion object {
const val NETWORK_ID = "ethereum"
}
}