Updated on 2026-08-14

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

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.data.addressbook"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.addressBook)
implementation(projects.domain.common)
implementation(projects.domain.models)
// endregion
// region SDK
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Testing
testImplementation(projects.test.core)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,245 @@
package com.tangem.data.addressbook
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.left
import arrow.core.right
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
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.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
@Suppress("LongParameterList")
internal class DefaultAddressBookRepository(
private val blobStore: AddressBookBlobStore,
private val cipher: AddressBookCipher,
private val addressBookApi: AddressBookApi,
private val eTagsStore: ETagsStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val timestampProvider: IsoTimestampProvider,
private val dispatchers: CoroutineDispatcherProvider,
) : AddressBookRepository {
private val writeMutex = Mutex()
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getAllContacts(): Flow<List<Contact>> {
return userWalletsListRepository.userWallets
.filterNotNull()
.flatMapLatest { wallets ->
val walletsById = wallets.associateBy { it.walletId.stringValue }
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
blobStore.getBlobs(ids).map { blobs ->
blobs.flatMap { blob ->
walletsById[blob.walletId]?.let { userWallet ->
decryptContacts(blob, userWallet)
}.orEmpty()
}
}
}
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
return blobStore.getBlob(userWalletId).map { blob ->
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
decryptContacts(blob, userWallet)
}
}
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
withContext(dispatchers.default) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
val userWallet = findUserWallet(blob.walletId) ?: return@withContext null
decryptContacts(blob, userWallet).find { it.name.value == name }
}
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
val userWallet = findUserWallet(contact.walletId.stringValue)
?: return@withLock AddressBookSyncError.Unknown.left()
val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact
persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged))
}
}
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
if (addressBook.contacts.none { it.id == id }) return@forEach
val remaining = addressBook.contacts.filterNot { it.id == id }
return@withLock persist(userWallet, addressBook.copy(contacts = remaining))
}
// No wallet held the contact — nothing to push, treat as success.
Unit.right()
}
}
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
val wallets = userWalletsListRepository.userWalletsSync()
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
// first failed chunk.
wallets.chunked(MAX_SYNC_WALLETS)
.fold(initial = Unit.right() as Either<AddressBookSyncError, Unit>) { acc, chunk ->
acc.flatMap { syncWalletsChunk(chunk) }
}
}
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
val request = SyncAddressBooksRequest(
wallets = wallets.map { wallet ->
SyncAddressBooksRequest.Wallet(
walletId = wallet.walletId.stringValue,
etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook),
)
},
)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
// Only wallets whose etag changed are returned; the rest keep their local copy.
response.items.forEach { item ->
val userWalletId = UserWalletId(stringValue = item.walletId)
blobStore.storeBlob(item.toBlob())
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
}
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to sync address books: $error")
error.toSyncError().left()
},
)
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
}
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
return decryptContacts(blob, userWallet)
}
/**
* Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success.
* On any failure (encryption, network, etag conflict, ) nothing is written locally.
*/
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either<AddressBookSyncError, Unit> {
val updatedAt = DateTime.parse(timestampProvider.now())
return cipher.encrypt(addressBook, userWallet, updatedAt)
.mapLeft { error ->
TangemLogger.e(
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
)
AddressBookSyncError.Unknown
}
.flatMap { blob -> pushBlob(addressBook.walletId, blob) }
}
private suspend fun pushBlob(
userWalletId: UserWalletId,
blob: AddressBookBlob,
): Either<AddressBookSyncError, Unit> {
// Absent etag means the book has not been created on the backend yet → omit If-Match to create it.
val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) {
addressBookApi.updateAddressBook(
walletId = blob.walletId,
eTag = eTag,
body = UpdateAddressBookRequest(
version = blob.version,
nonce = blob.nonce,
ciphertext = blob.ciphertext,
authTag = blob.authTag,
),
).bind()
}
blobStore.storeBlob(blob)
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag)
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
error.toSyncError().left()
},
)
}
private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob(
version = version,
walletId = walletId,
updatedAt = updatedAt,
nonce = nonce,
ciphertext = ciphertext,
authTag = authTag,
)
private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) {
is ApiResponseError.HttpException -> when (code) {
Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict
Code.NOT_FOUND -> AddressBookSyncError.NotFound
Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized
Code.BAD_REQUEST -> AddressBookSyncError.BadRequest
else -> AddressBookSyncError.Unknown
}
is ApiResponseError.NetworkException,
is ApiResponseError.TimeoutException,
-> AddressBookSyncError.Network
is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown
}
private suspend fun findUserWallet(walletId: String): UserWallet? =
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
private companion object {
const val MAX_SYNC_WALLETS = 20
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.data.addressbook.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.tangem.data.addressbook.DefaultAddressBookRepository
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AddressBookDataModule {
@Provides
@Singleton
fun provideAddressBookBlobStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): AddressBookBlobStore {
return DefaultAddressBookBlobStore(
dataStore = DataStoreFactory.create(
serializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = AddressBookBlob.serializer(),
),
),
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
scope = appScope,
),
)
}
@Provides
@Singleton
@Suppress("LongParameterList")
fun provideAddressBookRepository(
blobStore: AddressBookBlobStore,
cipher: AddressBookCipher,
addressBookApi: AddressBookApi,
eTagsStore: ETagsStore,
userWalletsListRepository: UserWalletsListRepository,
timestampProvider: IsoTimestampProvider,
dispatchers: CoroutineDispatcherProvider,
): AddressBookRepository {
return DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.addressbook.store
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
interface AddressBookBlobStore {
fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?>
fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>>
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
suspend fun storeBlob(blob: AddressBookBlob)
suspend fun deleteBlob(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,43 @@
package com.tangem.data.addressbook.store
import androidx.datastore.core.DataStore
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
internal class DefaultAddressBookBlobStore(
private val dataStore: DataStore<AddressBookBlobs>,
) : AddressBookBlobStore {
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
return dataStore.data
.map { it[userWalletId.stringValue] }
.distinctUntilChanged()
}
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue }
return dataStore.data
.map { stored -> stored.filterKeys { it in ids }.values.toList() }
.distinctUntilChanged()
}
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
return getStoredBlobs()[userWalletId.stringValue]
}
override suspend fun storeBlob(blob: AddressBookBlob) {
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
}
override suspend fun deleteBlob(userWalletId: UserWalletId) {
dataStore.updateData { stored -> stored - userWalletId.stringValue }
}
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
}

View file

@ -0,0 +1,412 @@
package com.tangem.data.addressbook
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookCryptoError
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
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.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.MutableStateFlow
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)
internal class DefaultAddressBookRepositoryTest {
private val blobStore: AddressBookBlobStore = mockk()
private val cipher: AddressBookCipher = mockk()
private val addressBookApi: AddressBookApi = mockk()
private val eTagsStore: ETagsStore = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val timestampProvider: IsoTimestampProvider = mockk()
private val userWallet: UserWallet = mockk {
every { walletId } returns UserWalletId(WALLET_A)
}
private val repository = DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun setup() {
clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider)
every { timestampProvider.now() } returns TIMESTAMP
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
coEvery { addressBookApi.syncAddressBooks(any()) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = emptyList()))
}
@Test
fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
val result = repository.getAllContacts().first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
repository.getAllContacts().first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest {
// Arrange
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest {
// Arrange
val existing = createContact(id = "c1", name = "Alice")
val added = createContact(id = "c2", name = "Bob")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(existing)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
val result = repository.saveContact(added)
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
}
@Test
fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
val newBlob = createBlob()
every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null
coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(Unit.right())
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) }
}
@Test
fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD
coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse()
// Act
repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) }
}
@Test
fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
}
@Test
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Network.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
// Arrange
val original = createContact(id = "c1", name = "Alice")
val updated = createContact(id = "c1", name = "Alice Updated")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(original)).right()
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.saveContact(updated)
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(updated)
}
@Test
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
// Arrange
val kept = createContact(id = "c1", name = "Alice")
val removed = createContact(id = "c2", name = "Bob")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
val result = repository.deleteContact(ContactId("c2"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(kept)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@Test
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
val requestSlot = slot<SyncAddressBooksRequest>()
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
val blobSlot = slot<AddressBookBlob>()
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
}
@Test
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
// Arrange
coEvery { addressBookApi.syncAddressBooks(any()) } returns
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
// Arrange
val alice = createContact(id = "c1", name = "Alice")
val bob = createContact(id = "c2", name = "Bob")
val blob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
every { cipher.decrypt(blob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right()
// Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
// Assert
assertThat(result).isEqualTo(bob)
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<T>
@Suppress("UNCHECKED_CAST")
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
walletId = walletId,
etag = ETAG_NEW,
version = AddressBookBlob.CURRENT_VERSION,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId(WALLET_A),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = iconColor,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addressEntries = emptyList(),
)
private fun createBlob(): AddressBookBlob = AddressBookBlob(
walletId = WALLET_A,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
const val ETAG_OLD = "etag-old"
const val ETAG_NEW = "etag-new"
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.data.addressbook.store
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.datastore.MockStateDataStore
import kotlinx.coroutines.flow.first
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)
internal class DefaultAddressBookBlobStoreTest {
private lateinit var store: DefaultAddressBookBlobStore
@BeforeEach
fun setup() {
store = DefaultAddressBookBlobStore(
dataStore = MockStateDataStore(default = emptyMap()),
)
}
@Test
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
// Act
store.storeBlob(blob)
// Assert
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
}
@Test
fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest {
// Arrange
val blobA = createBlob(walletId = WALLET_A)
val blobB = createBlob(walletId = WALLET_B)
store.storeBlob(blobA)
store.storeBlob(blobB)
// Act
val result = store.getBlob(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEqualTo(blobA)
}
@Test
fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest {
// Arrange
val blobA = createBlob(walletId = WALLET_A)
val blobB = createBlob(walletId = WALLET_B)
store.storeBlob(blobA)
store.storeBlob(blobB)
// Act
val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first()
val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first()
// Assert
assertThat(result).containsExactly(blobA, blobB)
assertThat(onlyA).containsExactly(blobA)
}
@Test
fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
store.storeBlob(blob)
// Act
store.deleteBlob(UserWalletId(WALLET_A))
// Assert
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull()
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull()
}
private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob(
walletId = walletId,
updatedAt = "2026-05-22T09:00:00.000Z",
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
}
}

View file

@ -37,5 +37,6 @@ interface ETagsStore {
enum class Key {
WalletAccounts,
UserTokens,
AddressBook,
}
}

View file

@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
) : FeedbackFeatureToggles {
override val isUsedeskEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
}

View file

@ -115,7 +115,7 @@ internal class DefaultOnrampRepository(
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
val result = onrampApi.getCountries(
val response = onrampApi.getCountries(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
@ -123,8 +123,12 @@ internal class DefaultOnrampRepository(
),
)
.getOrThrow()
.map(countryConverter::convert)
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryDao.upsertCountries(response.map { it.toEntity() })
}
val result = response.map(countryConverter::convert)
countriesStore.store(COUNTRIES_KEY, result)
result

View file

@ -1,54 +1,49 @@
package com.tangem.data.pushnotificationpreferences
import arrow.core.Either
import com.tangem.data.pushnotificationpreferences.converters.PushNotificationPreferencesConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
*
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
*
* Defaults for existing users (until BE migration runs): TX read from
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
* isVisible = true for all three.
* Preferences are cached in-memory (non-persistent); writes are full-replace PUTs and the server echo
* is cached as the source of truth.
*/
internal class DefaultWalletPushNotificationPreferencesRepository(
private val appPreferencesStore: AppPreferencesStore,
@Suppress("unused") private val tangemTechApi: TangemTechApi,
private val tangemTechApi: TangemTechApi,
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository {
private val walletMutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun preload(userWalletId: UserWalletId) {
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
val preferences = withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
// PushNotificationPreferencesConverter.convert(response)
loadDefaults(userWalletId)
}
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
if (isCached(userWalletId)) return
mutexFor(userWalletId).withLock {
if (isCached(userWalletId)) return
val preferences = fetch(userWalletId)
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
}
}
}
}
@ -64,9 +59,11 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.withCategory(category, isEnabled)
putAndCommit(userWalletId, updated)
mutexFor(userWalletId).withLock {
val current = currentOrFetch(userWalletId)
val updated = current.withCategory(category, isEnabled)
putAndCommit(userWalletId, updated)
}
}
override suspend fun setAllPreferences(
@ -75,39 +72,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
offersUpdates: Boolean,
priceAlerts: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
mutexFor(userWalletId).withLock {
val current = currentOrFetch(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
}
}
// Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults.
private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId)
private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
withContext(dispatchers.io) {
val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
private suspend fun isCached(userWalletId: UserWalletId): Boolean =
cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true
private fun mutexFor(userWalletId: UserWalletId): Mutex =
walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() }
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
// walletId = userWalletId.stringValue,
// body = PushNotificationPreferencesBody(
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
// ),
// ).getOrThrow()
val applied = withContext(dispatchers.io) {
val response = tangemTechApi.updatePushNotificationPreferences(
walletId = userWalletId.stringValue,
body = PushNotificationPreferencesBody(
areTransactionEventsEnabled = updated.transactionAlerts.isEnabled,
areOfferUpdatesEnabled = updated.offersUpdates.isEnabled,
arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
),
).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
false
return WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) }
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.data.pushnotificationpreferences.converters
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
@ -11,11 +10,8 @@ internal object PushNotificationPreferencesConverter :
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
WalletPushNotificationPreferences(
transactionAlerts = value.transactionAlerts.toDomain(),
offersUpdates = value.offersUpdates.toDomain(),
priceAlerts = value.priceAlerts.toDomain(),
transactionAlerts = PushNotificationPreference(isEnabled = value.areTransactionEventsEnabled),
offersUpdates = PushNotificationPreference(isEnabled = value.areOfferUpdatesEnabled),
priceAlerts = PushNotificationPreference(isEnabled = value.arePriceAlertsEnabled),
)
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
}

View file

@ -3,7 +3,6 @@ package com.tangem.data.pushnotificationpreferences.di
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -19,11 +18,9 @@ internal object PushNotificationPreferencesModule {
@Singleton
@Provides
fun providesWalletPushNotificationPreferencesRepository(
appPreferencesStore: AppPreferencesStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = dispatchers,

View file

@ -1,141 +1,220 @@
package com.tangem.data.pushnotificationpreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.turbine.test
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DefaultWalletPushNotificationPreferencesRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
private val repository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN server returns prefs WHEN preload THEN cache holds converted server state`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
// Act
repository.preload(userWalletId)
// Assert
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = false))
}
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN already preloaded WHEN preload called again THEN no second GET`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
// Act
repository.preload(userWalletId)
repository.preload(userWalletId)
// Assert
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN fetches baseline AND sends full-replace PUT AND caches echo`() =
runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
stubPut(userWalletId, transaction = true, offers = true, price = true)
// Act
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
// Assert
assertThat(result).isInstanceOf(Either.Right::class.java)
// The full-replace body changes only the tapped category on top of the server baseline.
coVerify(exactly = 1) {
tangemTechApi.updatePushNotificationPreferences(
userWalletId.stringValue,
PushNotificationPreferencesBody(
areTransactionEventsEnabled = true,
areOfferUpdatesEnabled = true,
arePriceAlertsEnabled = true,
),
)
}
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
}
}
@Test
fun `GIVEN preloaded state WHEN updatePreference THEN only the tapped category changes in the PUT body`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
stubPut(userWalletId, transaction = true, offers = false, price = false)
// Act
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
// Assert
coVerify(exactly = 1) {
tangemTechApi.updatePushNotificationPreferences(
userWalletId.stringValue,
PushNotificationPreferencesBody(
areTransactionEventsEnabled = true,
areOfferUpdatesEnabled = false,
arePriceAlertsEnabled = false,
),
)
}
}
@Test
fun `GIVEN write fails WHEN updatePreference THEN returns Left`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
repository.preload(userWalletId)
coEvery { tangemTechApi.updatePushNotificationPreferences(any(), any()) } throws IllegalStateException("boom")
// Act
val result = repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
// Assert
assertThat(result).isInstanceOf(Either.Left::class.java)
}
@Test
fun `GIVEN different wallets WHEN observed THEN each keeps its own server state`() = runTest {
// Arrange
stubGet(userWalletId, transaction = false, offers = false, price = false)
stubGet(otherWalletId, transaction = true, offers = true, price = true)
// Assert
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = false, offers = false, price = false))
}
repository.observePreferences(otherWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
}
}
@Test
fun `GIVEN concurrent collectors WHEN preload races THEN a single GET is issued`() = runTest {
// Arrange
val gate = CompletableDeferred<Unit>()
coEvery { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } coAnswers {
gate.await()
ApiResponse.Success(PushNotificationPreferencesResponse(true, true, false))
}
// Act
launch { repository.preload(userWalletId) }
runCurrent()
launch { repository.preload(userWalletId) }
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN concurrent writes WHEN updatePreference races THEN serialized so no update is lost`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
val gate = CompletableDeferred<Unit>()
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } coAnswers {
val body = arg<PushNotificationPreferencesBody>(1)
gate.await()
ApiResponse.Success(
PushNotificationPreferencesResponse(
body.areTransactionEventsEnabled,
body.areOfferUpdatesEnabled,
body.arePriceAlertsEnabled,
),
)
}
repository.preload(userWalletId)
// Act
launch { repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) }
runCurrent()
launch { repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) }
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
// Assert
coVerify(exactly = 2) { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) }
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = false, price = true))
}
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.priceAlerts.isEnabled).isTrue()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.transactionAlerts.isEnabled).isTrue()
}
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
}
@Test
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.transactionAlerts.isEnabled).isFalse()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
private fun stubPut(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(id.stringValue), any()) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
}
@Test
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isFalse()
}
repository.observePreferences(otherWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transaction),
offersUpdates = PushNotificationPreference(isEnabled = offers),
priceAlerts = PushNotificationPreference(isEnabled = price),
)
}

View file

@ -16,6 +16,7 @@ dependencies {
implementation(tangemDeps.card.core)
/** Core */
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.utils)

View file

@ -3,17 +3,23 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository
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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
@ -23,17 +29,19 @@ import java.math.BigInteger
class DefaultGaslessTransactionRepository(
private val gaslessTxServiceApi: GaslessTxServiceApi,
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
private val isGaslessV2Enabled: Boolean,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
private val allFeeRecipientAddress = mutableSetOf<String>()
private val allAddressesMutex = Mutex()
private val receiverAddressMutex = Mutex()
private var feeReceiverAddress: String? = null
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
eip7702Auth = eip7702Auth,
)
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
val response = if (isGaslessV2Enabled) {
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
} else {
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
}.getOrThrow()
if (!response.isSuccess) {
error("Gasless service returned unsuccessful response")
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
signedTransactionResultConverter.convert(response.result)
}
override suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
val blockchain = network.toBlockchain()
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
gaslessBatchTransaction = gaslessBatchTransactionData,
signature = signature,
userAddress = userAddress,
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
eip7702Auth = eip7702Auth,
)
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
if (!response.isSuccess) {
error("Gasless service returned unsuccessful response")
}
signedTransactionResultConverter.convert(response.result)
}
override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION
}
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
}
override suspend fun getGaslessFeeAddresses(): Set<String> {
return allAddressesMutex.withLock {
allFeeRecipientAddress.ifEmpty {
val allFeeAddresses = getAllFeeRecipientAddresses()
allFeeRecipientAddress.addAll(allFeeAddresses)
allFeeRecipientAddress
}
}
}
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
// TODO Replace with other backend call to get all fee recipient addresses when available
return setOf(getTokenFeeReceiverAddress())
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
.getOrNull()
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
}
private companion object {
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository
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
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
txHash = "0x000",
)
override suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
txHash = "0x000",
)
override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.utils.converter.Converter
/**
* Converts domain [Eip7702Authorization] to its DTO representation.
* Shared by both single-transaction and batch-transaction request builders.
*/
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
return Eip7702AuthorizationDTO(
chainId = value.chainId,
address = value.address,
nonce = value.nonce.toString(),
yParity = value.yParity,
r = value.r,
s = value.s,
)
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
/**
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
* Combines batch transaction data with signature and user information.
*
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
* to avoid duplicating mapping logic.
*/
class GaslessBatchTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
) {
/**
* Creates complete gasless batch transaction request.
*
* @param gaslessBatchTransaction domain model of batch transaction
* @param signature transaction signature in hex format (with 0x prefix)
* @param userAddress user's Ethereum address
* @param chainId blockchain network chain ID
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
* @return complete request ready for API submission
*/
fun build(
gaslessBatchTransaction: GaslessBatchTransactionData,
signature: String,
userAddress: String,
chainId: Int,
eip7702Auth: Eip7702Authorization? = null,
): GaslessBatchTransactionRequest {
return GaslessBatchTransactionRequest(
gaslessTransaction = GaslessBatchTransactionDataDTO(
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
fee = converter.convertFee(gaslessBatchTransaction.fee),
nonce = gaslessBatchTransaction.nonce.toString(),
),
signature = signature,
userAddress = userAddress,
chainId = chainId,
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
/**
* Builder for creating complete GaslessTransactionRequest from domain model.
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
*/
class GaslessTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
) {
/**
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
signature = signature,
userAddress = userAddress,
chainId = chainId,
eip7702Auth = eip7702Auth?.toDTO(),
)
}
/**
* Converts domain Eip7702Authorization to DTO.
*/
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
return Eip7702AuthorizationDTO(
chainId = chainId,
address = address,
nonce = nonce.toString(),
yParity = yParity,
r = r,
s = s,
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
)
}
}

View file

@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
* Note: This converter only handles the transaction data conversion.
* Additional fields (signature, userAddress, chainId) must be added separately
* to create complete GaslessTransactionRequest.
*
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
*/
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
class GaslessTxDataToGaslessRequestConverter(
private val shouldIncludeGasLimit: Boolean = true,
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
return GaslessTransactionDataDTO(
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
)
}
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
return TransactionData(
to = transaction.to,
value = transaction.value.toString(),
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
data = transaction.data.toHexString().formatHex(),
)
}
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
return FeeData(
feeToken = fee.feeToken,
maxTokenFee = fee.maxTokenFee.toString(),

View file

@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.*
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.demo.models.DemoConfig
@ -84,10 +87,17 @@ internal object TransactionDataModule {
fun provideGaslessTransactionRepository(
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
gaslessTxServiceApi: GaslessTxServiceApi,
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
featureTogglesManager: FeatureTogglesManager,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): GaslessTransactionRepository {
return DefaultGaslessTransactionRepository(
gaslessTxServiceApi = gaslessTxServiceApi,
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
coroutineDispatcherProvider = coroutineDispatcherProvider,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)

View file

@ -0,0 +1,117 @@
package com.tangem.data.transaction
import com.google.common.truth.Truth.assertThat
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultGaslessTransactionRepositoryTest {
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
@BeforeEach
fun resetMocks() {
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
}
private fun createRepository() = DefaultGaslessTransactionRepository(
gaslessTxServiceApi = gaslessTxServiceApi,
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
isGaslessV2Enabled = true,
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)
private fun stubFeeRecipientSuccess(address: String) {
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
data = GaslessServiceResponse(
result = GaslessFeeRecipient(address = address),
isSuccess = true,
timestamp = "2026-06-11T00:00:00.000Z",
),
)
}
private fun stubFeeRecipientFailure() {
@Suppress("UNCHECKED_CAST")
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
}
@Test
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
// Arrange
stubFeeRecipientSuccess(BACKEND_ADDRESS)
val repository = createRepository()
// Act
val actual = repository.getGaslessFeeAddresses()
// Assert
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
}
@Test
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
// Arrange
stubFeeRecipientFailure()
val repository = createRepository()
// Act
val actual = repository.getGaslessFeeAddresses()
// Assert
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
}
@Test
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
// Arrange
stubFeeRecipientFailure()
val repository = createRepository()
val firstResult = repository.getGaslessFeeAddresses()
stubFeeRecipientSuccess(BACKEND_ADDRESS)
// Act
val secondResult = repository.getGaslessFeeAddresses()
// Assert
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
}
@Test
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
// Arrange
stubFeeRecipientSuccess(BACKEND_ADDRESS)
val repository = createRepository()
// Act
repository.getGaslessFeeAddresses()
repository.getGaslessFeeAddresses()
// Assert
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
}
private companion object {
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
}
}

View file

@ -0,0 +1,193 @@
package com.tangem.data.transaction.convertes
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.junit.jupiter.api.Test
import java.math.BigInteger
class GaslessBatchTransactionRequestBuilderTest {
private val builder = GaslessBatchTransactionRequestBuilder()
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
private val tx1Data = byteArrayOf(0x12, 0x34)
private val tx1DataHex = "0x1234"
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
private val tx2DataHex = "0xABCD"
private val tx1 = GaslessTransactionData.Transaction(
to = "0xContractA",
value = BigInteger("100"),
gasLimit = BigInteger("120000"),
data = tx1Data,
)
private val tx2 = GaslessTransactionData.Transaction(
to = "0xContractB",
value = BigInteger("0"),
gasLimit = BigInteger("150000"),
data = tx2Data,
)
private val fee = GaslessTransactionData.Fee(
feeToken = "0xFeeToken",
maxTokenFee = BigInteger("500"),
coinPriceInToken = BigInteger("200"),
feeTransferGasLimit = BigInteger("21000"),
baseGas = BigInteger("60000"),
feeReceiver = "0xFeeReceiver",
)
private val nonce = BigInteger("42")
private val batchData = GaslessBatchTransactionData(
transactions = listOf(tx1, tx2),
fee = fee,
nonce = nonce,
)
@Test
fun `build - transactions list has correct size and order`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.gaslessTransaction.transactions).hasSize(2)
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
}
@Test
fun `build - transaction data fields are encoded correctly`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
val txDtoList = result.gaslessTransaction.transactions
// data bytes are hex-encoded with 0x prefix (uppercase)
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
// value is BigInteger.toString()
assertThat(txDtoList[0].value).isEqualTo("100")
assertThat(txDtoList[1].value).isEqualTo("0")
// v2: per-call gasLimit is BigInteger.toString()
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
}
@Test
fun `build - v1 converter omits per-call gasLimit`() {
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
val v1Builder = GaslessBatchTransactionRequestBuilder(
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
)
// Act
val result = v1Builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
val txDtoList = result.gaslessTransaction.transactions
assertThat(txDtoList[0].gasLimit).isNull()
assertThat(txDtoList[1].gasLimit).isNull()
// other fields are unaffected
assertThat(txDtoList[0].value).isEqualTo("100")
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
}
@Test
fun `build - fee fields are all toString of BigInteger inputs`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
val feeDto = result.gaslessTransaction.fee
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
assertThat(feeDto.maxTokenFee).isEqualTo("500")
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
assertThat(feeDto.baseGas).isEqualTo("60000")
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
}
@Test
fun `build - nonce is toString of BigInteger input`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
}
@Test
fun `build - top-level signature, userAddress, chainId pass through`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xDeadBeef",
userAddress = "0xAlice",
chainId = 137,
)
assertThat(result.signature).isEqualTo("0xDeadBeef")
assertThat(result.userAddress).isEqualTo("0xAlice")
assertThat(result.chainId).isEqualTo(137)
}
@Test
fun `build - eip7702Auth is null when not provided`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.eip7702Auth).isNull()
}
@Test
fun `build - eip7702Auth maps correctly when provided`() {
val auth = Eip7702Authorization(
chainId = 1,
address = "0xEntryPoint",
nonce = BigInteger("7"),
yParity = 0,
r = "0xRValue",
s = "0xSValue",
)
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
eip7702Auth = auth,
)
val authDto = result.eip7702Auth
assertThat(authDto).isNotNull()
assertThat(authDto!!.chainId).isEqualTo(1)
assertThat(authDto.address).isEqualTo("0xEntryPoint")
assertThat(authDto.nonce).isEqualTo("7")
assertThat(authDto.yParity).isEqualTo(0)
assertThat(authDto.r).isEqualTo("0xRValue")
assertThat(authDto.s).isEqualTo("0xSValue")
}
}

View file

@ -31,6 +31,8 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)

View file

@ -9,6 +9,7 @@ import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
@ -22,6 +23,7 @@ import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
private val expressRepository: ExpressRepository,
private val onrampRepository: OnrampRepository,
private val getWalletsUseCase: GetWalletsUseCase,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
@ -30,9 +32,6 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val fetchers = ConcurrentHashMap<UserWalletId, WalletTxHistoryFetcher>()
/** Wallets whose express providers were already loaded — to load them at most once per wallet. */
private val providersLoadedWallets = mutableSetOf<UserWalletId>()
init {
defaultLaunchIn(buildFlow())
}
@ -53,11 +52,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.stateIn(this)
walletsFlow.value.keys.createForNewWallets()
walletsFlow.value.values.firstOrNull()?.let { wallet ->
loadExpressProviders(wallet)
loadOnrampCountries(wallet)
}
selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger?
.onEach { wallet -> loadExpressProviders(wallet) }
.launchIn(this)
walletsFlow
@ -79,13 +81,17 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
}
private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) {
// Load once per wallet: `add` returns false if this walletId was already loaded.
if (!providersLoadedWallets.add(wallet.walletId)) return
flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) }
.retryThreeTimes()
.launchIn(this)
}
private fun ProducerScope<*>.loadOnrampCountries(wallet: UserWallet) {
flow { emit(onrampRepository.fetchCountries(userWallet = wallet)) }
.retryThreeTimes()
.launchIn(this)
}
private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }

View file

@ -5,9 +5,16 @@ import com.tangem.data.common.converter.ExpressProviderConverter
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
@ -36,6 +43,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val expressHistoryDao: ExpressHistoryDao,
private val expressTransactionAssetFactory: ExpressTransactionAssetFactory,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : TxHistoryRepositoryV2 {
@ -44,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val expressProviderConverter = ExpressProviderConverter()
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
private val onrampCountryConverter = OnrampCountryConverter()
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
override fun getExpressHistory(
@ -84,38 +93,80 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
).distinctUntilChanged(),
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
buildList<ExpressTx> {
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
)
add(swapConverter.convert(input))
}
incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
)
add(swapConverter.convert(input))
}
onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider())
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries ->
buildExpressHistory(
userWalletId = userWalletId,
sources = ExpressHistorySources(
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
providers = providers,
countries = countries,
),
)
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
/** The reactive express-history inputs gathered from the DB in a single [combine] tick. */
private data class ExpressHistorySources(
val outgoingSwaps: List<ExpressExchangeEntity>,
val incomingSwaps: List<ExpressExchangeEntity>,
val onramps: List<ExpressOnrampEntity>,
val providers: Map<String, ExpressProviderEntity>,
val countries: Map<String, OnrampCountryEntity>,
)
private suspend fun buildExpressHistory(
userWalletId: UserWalletId,
sources: ExpressHistorySources,
): List<ExpressTx> {
val currencies = expressTransactionAssetFactory.create(
userWalletId = userWalletId,
outgoingSwaps = sources.outgoingSwaps,
incomingSwaps = sources.incomingSwaps,
onramps = sources.onramps,
)
fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert)
fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert)
return buildList {
sources.outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
sources.incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
sources.onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
toCurrency = currencies[entity.to.toAssetId()],
country = entity.countryCode.onrampCountry(),
)
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
}
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,

View file

@ -9,6 +9,8 @@ import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
@ -26,7 +28,7 @@ import java.math.BigDecimal
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
tx = convertExchangeTransaction(value.entity, value.provider),
tx = convertExchangeTransaction(value),
isOutgoing = value.isOutgoing,
txInfo = null,
)
@ -35,6 +37,8 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
val fromCurrency: CryptoCurrency? = null,
val toCurrency: CryptoCurrency? = null,
)
}
@ -51,47 +55,62 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
payoutHash = entity.payoutHash,
fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toBigDecimalOrZero(),
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
decimals = entity.fromPrecision,
type = AmountType.FiatType(code = entity.fromCurrencyCode),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
country = value.country,
),
txInfo = null,
)
}
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
data class Input(
val entity: ExpressOnrampEntity,
val provider: ExpressProvider?,
val toCurrency: CryptoCurrency? = null,
val country: OnrampCountry? = null,
)
}
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
val entity = value.entity
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = provider,
provider = value.provider,
payinHash = entity.payinHash,
payoutHash = entity.payoutHash,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
decimals = entity.from.decimals,
cryptoCurrency = value.fromCurrency,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
)
}
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
/**
* Parses a raw minimal-unit amount string from the express backend and scales it to the human-readable
* value promised by [ExpressTransactionAsset.amount] (and the onramp fiat [Amount.value]).
*/
private fun String.toScaledBigDecimal(decimals: Int): BigDecimal =
(this.toBigDecimalOrNull() ?: BigDecimal.ZERO).movePointLeft(decimals)
/**
* Active (non-terminal) RAW status values passed to the DAO `observe` queries so in-progress deals

View file

@ -0,0 +1,29 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.utils.converter.Converter
/** Maps a persisted [OnrampCountryEntity] into the domain [OnrampCountry]. */
internal class OnrampCountryConverter : Converter<OnrampCountryEntity, OnrampCountry> {
override fun convert(value: OnrampCountryEntity): OnrampCountry {
return OnrampCountry(
id = "${value.alpha3}-${value.name}",
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = OnrampCurrency(
name = value.defaultCurrency.name,
code = value.defaultCurrency.code,
image = value.defaultCurrency.image,
precision = value.defaultCurrency.precision,
unit = value.defaultCurrency.unit,
),
onrampAvailable = value.isOnrampAvailable,
)
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.data.txhistory.repository.factory
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* Resolves a portfolio [CryptoCurrency] for every express asset (network id + contract address) referenced by a
* batch of exchange/onramp entities.
*
* Strategy: read every account of every wallet ONCE (via [MultiAccountListSupplier]) and match each express asset
* against the flattened portfolio currencies by network id + contract address. When nothing matches notably
* tokens that are not present in any portfolio a coin is built for the asset's network as a fallback (for now).
*/
internal class ExpressTransactionAssetFactory @Inject constructor(
private val multiAccountListSupplier: MultiAccountListSupplier,
private val userWalletsListRepository: UserWalletsListRepository,
excludedBlockchains: ExcludedBlockchains,
) {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
/**
* Builds a `assetId -> resolved currency` map covering both legs of every swap and the to-leg of every onramp.
* Entries whose currency could not be resolved at all (no match and no fallback coin) are omitted.
*/
suspend fun create(
userWalletId: UserWalletId,
outgoingSwaps: List<ExpressExchangeEntity>,
incomingSwaps: List<ExpressExchangeEntity>,
onramps: List<ExpressOnrampEntity>,
): Map<ExpressAsset.ID, CryptoCurrency> {
val assetIds = buildSet {
(outgoingSwaps + incomingSwaps).forEach { entity ->
add(entity.from.toAssetId())
add(entity.to.toAssetId())
}
onramps.forEach { entity -> add(entity.to.toAssetId()) }
}
if (assetIds.isEmpty()) return emptyMap()
val portfolioCurrencies = multiAccountListSupplier.invoke()
.first()
.flatMap { accountList -> accountList.flattenCurrencies() }
val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it.walletId == userWalletId }
return buildMap {
assetIds.forEach { id ->
val currency = portfolioCurrencies.findMatching(id) ?: createFallbackCoin(id, userWallet)
if (currency != null) put(id, currency)
}
}
}
private fun List<CryptoCurrency>.findMatching(id: ExpressAsset.ID): CryptoCurrency? {
val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
return firstOrNull { currency ->
currency.network.rawId == id.networkId &&
if (isCoin) {
currency is CryptoCurrency.Coin
} else {
currency is CryptoCurrency.Token &&
currency.contractAddress.equals(id.contractAddress, ignoreCase = true)
}
}
}
// TODO txHistory: tokens that are not in any portfolio cannot be resolved yet — fall back to a coin on the asset's
// network.
private fun createFallbackCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? {
userWallet ?: return null
return cryptoCurrencyFactory.createCoin(
networkId = id.networkId,
extraDerivationPath = null,
userWallet = userWallet,
)
}
}
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)

View file

@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -14,7 +15,6 @@ import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.job
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach
@ -29,14 +29,16 @@ internal class DefaultAppTxHistoryFetcherTest {
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
private val expressRepository: ExpressRepository = mockk()
private val onrampRepository: OnrampRepository = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository)
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository, onrampRepository)
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
coEvery { expressRepository.getProviders(any(), any()) } returns emptyList()
coEvery { onrampRepository.fetchCountries(any()) } returns emptyList()
}
@Test
@ -147,15 +149,16 @@ internal class DefaultAppTxHistoryFetcherTest {
}
@Test
fun `loads express providers when selected wallet is multi-currency`() = runTest {
fun `loads express providers and onramp countries for the first wallet on init`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
every { walletFetcherFactory.create(WALLET_ID_1) } returns relaxedWalletFetcher()
// Act
createFetcher(utils)
@ -163,48 +166,40 @@ internal class DefaultAppTxHistoryFetcherTest {
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(exactly = 1) { onrampRepository.fetchCountries(wallet) }
}
@Test
fun `loads express providers only once per wallet`() = runTest {
fun `does not load express data when there are no wallets`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
// Same wallet selected several times.
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet, wallet, wallet)
// Act
createFetcher(utils)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(inverse = true) { expressRepository.getProviders(any(), any()) }
coVerify(inverse = true) { onrampRepository.fetchCountries(any()) }
}
@Test
fun `provider loading failure does not break the wallet pipeline`() = runTest {
// Arrange
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
coEvery { expressRepository.getProviders(any(), any()) } throws RuntimeException("boom")
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act — the provider error is swallowed, so the wallet pipeline must keep working.
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Assert
@ -220,6 +215,7 @@ internal class DefaultAppTxHistoryFetcherTest {
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
utils = utils,
expressRepository = expressRepository,
onrampRepository = onrampRepository,
getWalletsUseCase = getWalletsUseCase,
selectedWalletUseCase = selectedWalletUseCase,
walletTxHistoryFetcherFactory = walletFetcherFactory,

View file

@ -31,8 +31,9 @@ internal class ExpressTxHistoryConverterTest {
assertThat(swap.txInfo).isNull()
assertThat(swap.tx.status).isEqualTo(ExpressExchangeStatus.Waiting)
assertThat(swap.createdAtMillis).isEqualTo(DateTime.parse(CREATED_AT).millis)
assertThat(swap.tx.fromAsset.amount).isEqualTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.001"))
// Raw backend amounts are scaled by decimals into the human-readable value the domain model promises.
assertThat(swap.tx.fromAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
}
@Test
@ -50,14 +51,14 @@ internal class ExpressTxHistoryConverterTest {
@Test
fun `GIVEN exchange entity with actual amount WHEN toOutgoingSwap THEN to-asset uses actual amount`() {
// Arrange
val entity = createExchangeEntity(toAmount = "0.001", toActualAmount = "0.00099")
// Arrange (raw minimal-unit amounts, to-asset decimals = 8)
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
// Assert
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
}
@Test
@ -73,17 +74,18 @@ internal class ExpressTxHistoryConverterTest {
assertThat(onramp.txInfo).isNull()
assertThat(onramp.tx.status).isEqualTo(ExpressOnrampStatus.Finished)
assertThat(onramp.tx.fromFiat.currencySymbol).isEqualTo("USD")
assertThat(onramp.tx.fromFiat.value).isEqualTo(BigDecimal("100.0"))
assertThat(onramp.tx.fromFiat.value).isEquivalentAccordingToCompareTo(BigDecimal("100"))
assertThat(onramp.tx.fromFiat.decimals).isEqualTo(2)
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
assertThat(onramp.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
}
private fun createExchangeEntity(
payinHash: String? = "payin",
payoutHash: String? = "payout",
status: String = "waiting",
toAmount: String = "0.001",
// Raw minimal-unit amount (to-asset decimals = 8) → 0.001
toAmount: String = "100000",
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
@ -111,7 +113,8 @@ internal class ExpressTxHistoryConverterTest {
contractAddress = "",
network = "ethereum",
decimals = 18,
amount = "1.5",
// Raw minimal-unit amount (decimals = 18) → 1.5
amount = "1500000000000000000",
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
@ -139,13 +142,15 @@ internal class ExpressTxHistoryConverterTest {
createdAt = CREATED_AT,
updatedAt = CREATED_AT,
fromCurrencyCode = "USD",
fromAmount = "100.0",
// Raw minimal-unit fiat amount (precision = 2) → 100
fromAmount = "10000",
fromPrecision = 2,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = "0xtoken",
network = "ethereum",
decimals = 18,
amount = "0.5",
// Raw minimal-unit amount (decimals = 18) → 0.5
amount = "500000000000000000",
actualAmount = null,
),
paymentMethod = "card",

View file

@ -0,0 +1,25 @@
package com.tangem.data.walletmanager.utils
import com.tangem.domain.models.network.SdkAmount
import com.tangem.domain.models.network.SdkAmountType
import com.tangem.blockchain.common.Amount as BlockchainAmount
import com.tangem.blockchain.common.AmountType as BlockchainAmountType
/** Maps the blockchain SDK [BlockchainAmount] to the serializable domain [SdkAmount]. */
internal fun BlockchainAmount.toDomain(): SdkAmount = SdkAmount(
currencySymbol = currencySymbol,
value = value,
decimals = decimals,
type = type.toDomain(),
)
private fun BlockchainAmountType.toDomain(): SdkAmountType = when (this) {
BlockchainAmountType.Coin -> SdkAmountType.Coin
BlockchainAmountType.Reserve -> SdkAmountType.Reserve
is BlockchainAmountType.FeeResource -> SdkAmountType.FeeResource(name = name)
is BlockchainAmountType.Token -> SdkAmountType.Token(contractAddress = token.contractAddress, id = token.id)
is BlockchainAmountType.TokenYieldSupply -> SdkAmountType.Token(
contractAddress = token.contractAddress,
id = token.id,
)
}

View file

@ -32,6 +32,7 @@ internal class SdkTransactionHistoryItemConverter(
},
type = typeConverter.convert(value),
amount = requireNotNull(value.amount.value) { "Transaction amount value must not be null" },
fee = value.fee.toDomain(),
)
private fun SdkTransactionHistoryItem.SourceType.toDomain(): TxInfo.SourceType = when (this) {

View file

@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter(
},
type = getTransactionType(value),
amount = amount,
fee = value.fee?.amount?.toDomain(),
)
}

View file

@ -0,0 +1,84 @@
package com.tangem.data.walletmanager.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.walletmanager.model.SmartContractMethod
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SdkTransactionTypeConverterTest {
private val converter = SdkTransactionTypeConverter(
smartContractMethods = mapOf(
GASLESS_METHOD_ID to SmartContractMethod(info = null, source = null, name = "gaslessTransaction"),
),
yieldSupplyAddresses = emptySet(),
gaslessFeeAddresses = setOf(FEE_RECIPIENT),
)
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = converter.convert(gaslessItem(model.destination))
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
ConvertModel(
destination = singleUser(RECIPIENT),
expected = TxInfo.TransactionType.UnknownOperation,
),
ConvertModel(
destination = singleUser(FEE_RECIPIENT),
expected = TxInfo.TransactionType.GaslessFee,
),
ConvertModel(
destination = singleUser(FEE_RECIPIENT.uppercase()),
expected = TxInfo.TransactionType.GaslessFee,
),
ConvertModel(
destination = TransactionHistoryItem.DestinationType.Multiple(
addressTypes = listOf(
TransactionHistoryItem.AddressType.User(RECIPIENT),
TransactionHistoryItem.AddressType.User(FEE_RECIPIENT),
),
),
expected = TxInfo.TransactionType.UnknownOperation,
),
)
private fun gaslessItem(destination: TransactionHistoryItem.DestinationType) = TransactionHistoryItem(
txHash = "0xhash",
timestamp = 0L,
isOutgoing = true,
destinationType = destination,
sourceType = TransactionHistoryItem.SourceType.Single(SENDER),
status = TransactionHistoryItem.TransactionStatus.Confirmed,
type = TransactionHistoryItem.TransactionType.ContractMethod(id = GASLESS_METHOD_ID),
amount = mockk(),
fee = mockk(),
)
private fun singleUser(address: String) = TransactionHistoryItem.DestinationType.Single(
addressType = TransactionHistoryItem.AddressType.User(address),
)
internal data class ConvertModel(
val destination: TransactionHistoryItem.DestinationType,
val expected: TxInfo.TransactionType,
)
private companion object {
const val GASLESS_METHOD_ID = "0x6234d42b"
const val SENDER = "0x9ffd974772bda94d288240c1b22f367ce75ccd7f"
const val RECIPIENT = "0x2222222222222222222222222222222222222222"
const val FEE_RECIPIENT = "0x1111111111111111111111111111111111111111"
}
}

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.core.analytics)
/** Domain */
implementation(projects.domain.transaction)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.walletManager)

View file

@ -8,6 +8,7 @@ import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -240,6 +241,37 @@ internal class DefaultYieldSupplyTransactionRepository(
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
}
override suspend fun getYieldModuleVersionStatus(
userWalletId: UserWalletId,
network: Network,
): YieldModuleVersionStatus = withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
walletManager.checkModuleVersionStatus()
}
override suspend fun createPartialWithdrawCallData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
amount: Amount,
): SmartContractCallData = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData(
tokenContractAddress = cryptoCurrency.contractAddress,
amount = amount,
)
val versionStatus = walletManager.checkModuleVersionStatus()
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData)
}
private suspend fun getYieldTokenStatus(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.transaction.GaslessYieldRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.domain.yield.supply.YieldSupplyRepository
@ -41,6 +42,14 @@ internal object YieldSupplyDataModule {
)
}
@Provides
@Singleton
fun provideGaslessYieldRepository(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): GaslessYieldRepository {
return yieldSupplyTransactionRepository
}
@Provides
@Singleton
fun provideYieldSupplyMarketRepository(

View file

@ -0,0 +1,194 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.common.WalletManager
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
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)
internal class DefaultYieldModuleAddressProviderTest {
private val walletManager: WalletManager = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val provider = DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
private val otherWalletId = UserWalletId("fedcba543210")
private val network = network()
@BeforeEach
fun setUp() {
clearMocks(walletManager, walletManagersFacade)
provider.invalidate(null)
coEvery {
walletManagersFacade.getOrCreateWalletManager(any(), any(), any())
} returns walletManager
}
@Test
fun `GIVEN non-zero address WHEN getOrFetch THEN returns and caches it`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
// Act
val first = provider.getOrFetch(userWalletId, network)
val second = provider.getOrFetch(userWalletId, network)
// Assert
assertThat(first).isEqualTo(ADDRESS)
assertThat(second).isEqualTo(ADDRESS)
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN zero address WHEN getOrFetch THEN returns null and does not cache`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns EthereumUtils.ZERO_ADDRESS
// Act
val first = provider.getOrFetch(userWalletId, network)
val second = provider.getOrFetch(userWalletId, network)
// Assert — null result is never cached, so the manager is queried again
assertThat(first).isNull()
assertThat(second).isNull()
coVerify(exactly = 2) { walletManager.getYieldModuleAddress() }
}
@Test
fun `GIVEN missing wallet manager WHEN getOrFetch THEN throws`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
// Act
val error = runCatching { provider.getOrFetch(userWalletId, network) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN cached address WHEN invalidate for that wallet THEN it is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
// Act
provider.invalidate(userWalletId)
provider.getOrFetch(userWalletId, network)
// Assert
coVerify(exactly = 2) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN two cached wallets WHEN invalidate one THEN only that one is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Act
provider.invalidate(userWalletId)
provider.getOrFetch(userWalletId, network) // refetched
provider.getOrFetch(otherWalletId, network) // still cached
// Assert — 2 initial fetches + 1 refetch for the invalidated wallet only
coVerify(exactly = 3) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN cached addresses WHEN invalidate all THEN every wallet is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Act
provider.invalidate(null)
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Assert — 2 initial + 2 after a full invalidation
coVerify(exactly = 4) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN two concurrent fetches for the same key WHEN one is in flight THEN manager is created once`() = runTest {
// Arrange — io dispatcher we control so both callers reach the mutex before the cache is populated
val testDispatcher = StandardTestDispatcher(testScheduler)
val concurrentProvider = DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
val proceed = CompletableDeferred<Unit>()
coEvery { walletManager.getYieldModuleAddress() } coAnswers {
proceed.await()
ADDRESS
}
// Act — both pass the lock-free pre-check; one holds the lock and fetches, the other waits on it
val first = launch { concurrentProvider.getOrFetch(userWalletId, network) }
val second = launch { concurrentProvider.getOrFetch(userWalletId, network) }
runCurrent()
// At the barrier both callers have passed the lock-free pre-check (cache still empty): one holds the mutex and
// awaits the gate, the other is blocked on the lock. Asserting neither completed proves the second did NOT
// short-circuit on the outer pre-check, so it must hit the in-lock double-check once released.
assertThat(first.isCompleted).isFalse()
assertThat(second.isCompleted).isFalse()
proceed.complete(Unit)
advanceUntilIdle()
first.join()
second.join()
// Assert — the second caller is served from cache via the in-lock double-check
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
coVerify(exactly = 1) { walletManager.getYieldModuleAddress() }
}
private fun network(): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
name = "Ethereum",
currencySymbol = "ETH",
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private companion object {
const val ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.yield.supply.YieldSupplyError
import org.junit.jupiter.api.Test
import java.io.IOException
internal class DefaultYieldSupplyErrorResolverTest {
@Test
fun `GIVEN a YieldSupplyError WHEN resolve THEN returns the same instance`() {
// Arrange
val error = YieldSupplyError.DataError(IOException("boom"))
// Act
val result = DefaultYieldSupplyErrorResolver.resolve(error)
// Assert
assertThat(result).isSameInstanceAs(error)
}
@Test
fun `GIVEN a generic throwable WHEN resolve THEN wraps it into DataError`() {
// Arrange
val throwable = IllegalStateException("unexpected")
// Act
val result = DefaultYieldSupplyErrorResolver.resolve(throwable)
// Assert
assertThat(result).isEqualTo(YieldSupplyError.DataError(throwable))
}
}

View file

@ -0,0 +1,470 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.WalletManager
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
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
import java.io.IOException
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldSupplyRepositoryTest {
private val yieldSupplyApi: YieldSupplyApi = mockk()
private val store: YieldMarketsStore = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk()
private val analyticsExceptionHandler: AnalyticsExceptionHandler = mockk(relaxed = true)
private val appPreferencesStore: AppPreferencesStore = mockk()
private val repository = DefaultYieldSupplyRepository(
yieldSupplyApi = yieldSupplyApi,
store = store,
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
analyticsExceptionHandler = analyticsExceptionHandler,
appPreferencesStore = appPreferencesStore,
)
private val userWalletId = UserWalletId("abcdef012345")
private val token = token()
@BeforeEach
fun setUp() {
clearMocks(yieldSupplyApi, store, walletManagersFacade, analyticsExceptionHandler)
}
// region markets
@Test
fun `GIVEN cached dtos WHEN getCachedMarkets THEN returns enriched domain`() = runTest {
// Arrange
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = 1))
// Act
val result = repository.getCachedMarkets()
// Assert — chainId 1 is enriched to its network id
assertThat(result).hasSize(1)
assertThat(result.first().backendId).isEqualTo("ethereum")
}
@Test
fun `GIVEN empty cache WHEN getCachedMarkets THEN returns empty list`() = runTest {
// Arrange
coEvery { store.getSyncOrNull() } returns null
// Act
val result = repository.getCachedMarkets()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN cached dto with unmapped chain id WHEN getCachedMarkets THEN backend id is null`() = runTest {
// Arrange — chainId -1 (the converter's default for a DTO without a chainId) maps to no network
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = -1))
// Act
val result = repository.getCachedMarkets()
// Assert
assertThat(result).hasSize(1)
assertThat(result.first().backendId).isNull()
}
@Test
fun `GIVEN api returns markets WHEN updateMarkets THEN stores dtos and returns domain`() = runTest {
// Arrange
val dto = marketDto(chainId = 1)
coEvery { yieldSupplyApi.getYieldMarkets(any()) } returns ApiResponse.Success(
YieldMarketsResponse(marketDtos = listOf(dto), lastUpdated = "now"),
)
// Act
val result = repository.updateMarkets()
// Assert
assertThat(result).containsExactly(YieldMarketTokenConverter.convert(dto))
coVerify(exactly = 1) { store.store(listOf(dto)) }
}
@Test
fun `GIVEN store flow WHEN getMarketsFlow THEN emits enriched domain`() = runTest {
// Arrange
every { store.get() } returns flowOf(listOf(marketDto(chainId = 1)))
// Act
val result = repository.getMarketsFlow().first()
// Assert
assertThat(result.first().backendId).isEqualTo("ethereum")
}
// endregion
// region token status / chart
@Test
fun `GIVEN evm token WHEN getTokenStatus THEN returns converted market token`() = runTest {
// Arrange
val dto = marketDto(chainId = 1)
coEvery { yieldSupplyApi.getYieldTokenStatus(1, token.contractAddress) } returns ApiResponse.Success(dto)
// Act
val result = repository.getTokenStatus(token)
// Assert
assertThat(result).isEqualTo(YieldMarketTokenConverter.convert(dto))
}
@Test
fun `GIVEN non-evm token WHEN getTokenStatus THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.getTokenStatus(nonEvm) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN evm token WHEN getTokenChart THEN returns converted chart`() = runTest {
// Arrange
coEvery { yieldSupplyApi.getYieldTokenChart(1, token.contractAddress) } returns ApiResponse.Success(
chartResponse(),
)
// Act
val result = repository.getTokenChart(token)
// Assert
assertThat(result.avr).isEqualTo(4.25)
assertThat(result.y).containsExactly(3.5).inOrder()
}
@Test
fun `GIVEN non-evm token WHEN getTokenChart THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.getTokenChart(nonEvm) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
// endregion
// region isYieldSupplySupported
@Test
fun `GIVEN supported yield provider WHEN isYieldSupplySupported THEN returns true`() = runTest {
// Arrange — WalletManager itself implements YieldSupplyProvider
val walletManager = mockk<WalletManager> { every { isSupported() } returns true }
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN unsupported yield provider WHEN isYieldSupplySupported THEN returns false`() = runTest {
// Arrange
val walletManager = mockk<WalletManager> { every { isSupported() } returns false }
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN no wallet manager WHEN isYieldSupplySupported THEN sends analytics and returns false`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isFalse()
verify { analyticsExceptionHandler.sendException(any()) }
}
// endregion
// region activate / deactivate
@Test
fun `GIVEN api returns active WHEN activateProtocol THEN returns true`() = runTest {
// Arrange
coEvery {
yieldSupplyApi.activateYieldModule(body = any(), userWalletId = any())
} returns ApiResponse.Success(statusResponse(isActive = true))
// Act
val result = repository.activateProtocol(userWalletId, token, ADDRESS)
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN non-evm token WHEN activateProtocol THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.activateProtocol(userWalletId, nonEvm, ADDRESS) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN api returns inactive WHEN deactivateProtocol THEN returns false`() = runTest {
// Arrange
coEvery { yieldSupplyApi.deactivateYieldModule(any()) } returns ApiResponse.Success(
statusResponse(isActive = false),
)
// Act
val result = repository.deactivateProtocol(token, ADDRESS)
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN non-evm token WHEN deactivateProtocol THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.deactivateProtocol(nonEvm, ADDRESS) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
// endregion
// region pending status (in-memory)
@Test
fun `GIVEN saved pending status WHEN getTokenProtocolPendingStatus THEN returns it`() = runTest {
// Arrange
val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L)
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
// Act
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
// Assert
assertThat(result).isEqualTo(status)
}
@Test
fun `GIVEN saved then cleared WHEN getTokenProtocolPendingStatus THEN returns null`() = runTest {
// Arrange
repository.saveTokenProtocolPendingStatus(
userWalletId,
token,
YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L),
)
// Act
repository.saveTokenProtocolPendingStatus(userWalletId, token, null)
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN saved status WHEN flow collected THEN emits the status`() = runTest {
// Arrange
val status = YieldSupplyPendingStatus.Exit(txIds = listOf("0x9"), createdAt = 1L)
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
// Act
val emitted = repository.getTokenProtocolPendingStatusFlow(userWalletId, token).first()
// Assert
assertThat(emitted).isEqualTo(status)
}
// endregion
// region pending tx hashes
@Test
fun `GIVEN unconfirmed and confirmed txs WHEN getPendingTxHashes THEN returns only unconfirmed hashes`() = runTest {
// Arrange
val walletManager = mockk<WalletManager> {
every { wallet.recentTransactions } returns mutableListOf(
tx(TransactionStatus.Unconfirmed, "0xUnconfirmed"),
tx(TransactionStatus.Confirmed, "0xConfirmed"),
)
}
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns walletManager
// Act
val result = repository.getPendingTxHashes(userWalletId, token)
// Assert
assertThat(result).containsExactly("0xUnconfirmed")
}
@Test
fun `GIVEN no wallet manager WHEN getPendingTxHashes THEN returns empty`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns null
// Act
val result = repository.getPendingTxHashes(userWalletId, token)
// Assert
assertThat(result).isEmpty()
}
// endregion
// region promo banner preference
@Test
fun `GIVEN stored flag WHEN getShouldShowYieldPromoBanner THEN emits it`() = runTest {
// Arrange
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
try {
every {
appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true)
} returns flowOf(false)
// Act
val result = repository.getShouldShowYieldPromoBanner().first()
// Assert
assertThat(result).isFalse()
} finally {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
}
@Test
fun `WHEN setShouldShowYieldPromoBanner THEN stores the value`() = runTest {
// Arrange
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
try {
coEvery {
appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false)
} returns Unit
// Act
repository.setShouldShowYieldPromoBanner(false)
// Assert
coVerify { appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false) }
} finally {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
}
// endregion
private fun tx(status: TransactionStatus, hash: String): TransactionData.Uncompiled = mockk {
every { this@mockk.status } returns status
every { this@mockk.hash } returns hash
}
private fun marketDto(chainId: Int) = YieldSupplyMarketTokenDto(
tokenAddress = "0xToken",
tokenSymbol = "USDT",
tokenName = "Tether",
apy = BigDecimal("5.5"),
decimals = 6,
isActive = true,
chainId = chainId,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
)
private fun chartResponse() = YieldTokenChartResponse(
underlying = "USDT",
market = "aave",
bucketSizeDays = 1,
period = "30d",
data = listOf(YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5"))),
averageApy = BigDecimal("4.25"),
)
private fun statusResponse(isActive: Boolean) = YieldModuleStatusResponse(
tokenAddress = "0xToken",
chainId = 1,
isActive = isActive,
activatedAt = null,
deactivatedAt = null,
)
private fun token(rawId: String = "ethereum", contractAddress: String = "0xToken"): CryptoCurrency.Token {
val derivationPath = Network.DerivationPath.None
val network = Network(
id = Network.ID(value = rawId, derivationPath = derivationPath),
name = "Net",
currencySymbol = "ETH",
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId),
),
network = network,
name = "TEST_TOKEN",
symbol = "TTK",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private companion object {
const val ADDRESS = "0x1111111111111111111111111111111111111111"
}
}

View file

@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
import com.google.common.truth.Truth
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest {
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
}
@Test
fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest {
coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
every { contractAddress } returns mockedContractAddress
every { decimals } returns 6
}
val amount = Amount(
currencySymbol = "USDC",
value = BigDecimal("1.5"),
decimals = 6,
type = AmountType.Token(
token = Token(
symbol = "USDC",
contractAddress = mockedContractAddress,
decimals = 6,
),
),
)
val result = repository.createPartialWithdrawCallData(
userWalletId = userWalletId,
cryptoCurrency = token,
amount = amount,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3")
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.yield.supply.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.domain.yield.supply.models.YieldMarketToken
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class YieldMarketTokenConverterTest {
@Test
fun `GIVEN fully populated dto WHEN convert THEN maps every field`() {
// Arrange
val dto = YieldSupplyMarketTokenDto(
tokenAddress = "0xToken",
tokenSymbol = "USDT",
tokenName = "Tether",
apy = BigDecimal("5.5"),
decimals = 6,
isActive = true,
chainId = 1,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
)
// Act
val result = YieldMarketTokenConverter.convert(dto)
// Assert
assertThat(result).isEqualTo(
YieldMarketToken(
tokenAddress = "0xToken",
chainId = 1,
apy = BigDecimal("5.5"),
isActive = true,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
backendId = null,
),
)
}
@Test
fun `GIVEN dto with null fields WHEN convert THEN applies defaults`() {
// Arrange
val dto = YieldSupplyMarketTokenDto()
// Act
val result = YieldMarketTokenConverter.convert(dto)
// Assert
assertThat(result).isEqualTo(
YieldMarketToken(
tokenAddress = "",
chainId = -1,
apy = BigDecimal.ZERO,
isActive = false,
maxFeeNative = BigDecimal.ZERO,
maxFeeUSD = BigDecimal.ZERO,
backendId = null,
),
)
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.data.yield.supply.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class YieldTokenChartConverterTest {
@Test
fun `GIVEN response with data points WHEN convert THEN splits avgApy into y and bucketIndex into x preserving order`() {
// Arrange
val response = response(
averageApy = BigDecimal("4.25"),
points = listOf(
YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5")),
YieldTokenChartResponse.DataPoint(bucketIndex = 1, avgApy = BigDecimal("4.0")),
YieldTokenChartResponse.DataPoint(bucketIndex = 2, avgApy = BigDecimal("5.0")),
),
)
// Act
val result = YieldTokenChartConverter.convert(response)
// Assert
assertThat(result).isEqualTo(
YieldSupplyMarketChartData(
y = listOf(3.5, 4.0, 5.0),
x = listOf(0.0, 1.0, 2.0),
avr = 4.25,
),
)
}
@Test
fun `GIVEN response with empty data WHEN convert THEN returns empty y and x with average`() {
// Arrange
val response = response(averageApy = BigDecimal("1.0"), points = emptyList())
// Act
val result = YieldTokenChartConverter.convert(response)
// Assert
assertThat(result).isEqualTo(
YieldSupplyMarketChartData(y = emptyList(), x = emptyList(), avr = 1.0),
)
}
private fun response(averageApy: BigDecimal, points: List<YieldTokenChartResponse.DataPoint>) =
YieldTokenChartResponse(
underlying = "USDT",
market = "aave",
bucketSizeDays = 1,
period = "30d",
data = points,
averageApy = averageApy,
)
}

View file

@ -0,0 +1,245 @@
package com.tangem.data.yield.supply.promo
import com.google.common.truth.Truth.assertThat
import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldPromoRepositoryTest {
private val tangemApi: TangemTechApi = mockk()
private val promoStore: YieldBoostPromoStore = mockk(relaxed = true)
private val statusStore: YieldBoostStatusStore = mockk(relaxed = true)
private val repository = DefaultYieldPromoRepository(
tangemApi = tangemApi,
promoStore = promoStore,
statusStore = statusStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() {
clearMocks(tangemApi, promoStore, statusStore)
}
// region getYieldBoostPromo
@Test
fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostPromo.None
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest {
// Arrange
val dto = matchingPromoDto()
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(dto)),
)
val expected = YieldBoostPromoConverter.convert(dto)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { promoStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(matchingPromoDto())),
)
// Act
repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null))),
)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(YieldBoostPromo.None)
coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostPromo.None
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { promoStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
// region getYieldBoostStatus
@Test
fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostStatus.NotStarted
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest {
// Arrange
val response = statusResponse()
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response)
val expected = YieldBoostStatusConverter.convert(response)
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { statusStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest {
// Arrange
coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse())
// Act
repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { statusStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
private fun matchingPromoDto() = PromotionsResponse.PromotionDto(
name = "yield-apr-boost",
all = PromotionsResponse.PromotionDto.All(
timeline = PromotionsResponse.PromotionDto.Timeline(
start = "2026-06-15T00:00:00.000Z",
end = "2027-06-15T22:00:00.000Z",
),
tokens = listOf(
PromotionsResponse.PromotionDto.PromoToken(
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = "ethereum",
),
),
status = "active",
link = "https://example.com/terms",
),
)
private fun statusResponse() = YieldBoostStatusResponse(
tokenName = "USD Coin",
networkId = "ethereum",
moduleAddress = "0xModule",
userAddress = "0xUser",
contractAddress = "0xContract",
promoEnrollmentStatus = "NOT_STARTED",
qualificationEndDate = null,
disqualificationReason = null,
)
}