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