Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:41:58 +04:00
parent 6848f91436
commit b8898092fb
21 changed files with 443 additions and 106 deletions

View file

@ -1,6 +1,9 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.datasource.api.common.response.getOrThrow
@ -11,8 +14,10 @@ import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaRespon
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
@ -25,12 +30,15 @@ import javax.inject.Inject
* Fetches express (exchange & onramp) transaction history from the API, persists it into the local database, and
* fetches any missing token metadata for the referenced assets.
*/
@Suppress("LongParameterList")
internal class DefaultExpressHistoryRepository @Inject constructor(
private val exchangeApi: TangemExpressApi,
private val onrampApi: OnrampApi,
private val expressHistoryDao: ExpressHistoryDao,
private val historyIndexDao: HistoryIndexDao,
private val expressSyncStateDao: ExpressSyncStateDao,
private val tokenInfoRepository: TokenInfoRepository,
private val database: TxHistoryDatabase,
private val appScope: AppCoroutineScope,
) : ExpressHistoryRepository {
@ -48,7 +56,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -72,7 +80,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -95,7 +103,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -119,7 +127,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -128,10 +136,13 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
return response
}
override suspend fun storeExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
if (items.isEmpty()) return
val entities = items.map { it.toEntity(ownerAddress) }
expressHistoryDao.upsertExchanges(entities)
override suspend fun storeExchanges(items: List<ExchangeItemResponse>) {
val entities = items.mapNotNull { it.toEntity() }
if (entities.isEmpty()) return
database.withTransaction {
expressHistoryDao.upsertExchanges(entities)
historyIndexDao.upsert(entities.flatMap { it.toHistoryIndexEntities() })
}
fetchMissingTokenInfo(
buildSet {
entities.forEach { entity ->
@ -142,10 +153,13 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
)
}
override suspend fun storeOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
override suspend fun storeOnramps(items: List<OnrampItemResponse>) {
if (items.isEmpty()) return
val entities = items.map { it.toEntity(ownerAddress) }
expressHistoryDao.upsertOnramps(entities)
val entities = items.map { it.toEntity() }
database.withTransaction {
expressHistoryDao.upsertOnramps(entities)
historyIndexDao.upsert(entities.map { it.toHistoryIndexEntity() })
}
fetchMissingTokenInfo(entities.mapTo(mutableSetOf()) { it.to.toAssetId() })
}

View file

@ -73,7 +73,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
val flow = combine(
flow = expressHistoryDao.observeOutgoingSwaps(
ownerAddress = address,
fromAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
@ -86,7 +86,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow3 = expressHistoryDao.observeIncomingOnramps(
ownerAddress = address,
payoutAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,

View file

@ -0,0 +1,31 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import org.joda.time.DateTime
/**
* Index rows for a swap: it shows on the from-token screen (outgoing, under `from_address`) and, when the payout lands
* on another owned token, on that screen too (incoming, under `payout_address`). The set de-duplicates the addresses so
* a swap whose from and payout addresses coincide is indexed once.
*/
internal fun ExpressExchangeEntity.toHistoryIndexEntities(): List<HistoryIndexEntity> {
val sortTimeMillis = DateTime.parse(createdAt).millis
return setOf(fromAddress, payoutAddress).map { address ->
HistoryIndexEntity(
type = HistoryIndexEntity.Type.EXCHANGE.value,
entityId = txId,
address = address,
sortTimeMillis = sortTimeMillis,
)
}
}
/** Onramp is always incoming: a single index row under the payout address. */
internal fun ExpressOnrampEntity.toHistoryIndexEntity(): HistoryIndexEntity = HistoryIndexEntity(
type = HistoryIndexEntity.Type.ONRAMP.value,
entityId = txId,
address = payoutAddress,
sortTimeMillis = DateTime.parse(createdAt).millis,
)

View file

@ -1,6 +1,9 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.google.common.truth.Truth.assertThat
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
@ -15,8 +18,11 @@ import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaRespon
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
@ -25,8 +31,12 @@ import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -37,21 +47,35 @@ internal class DefaultExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val historyIndexDao: HistoryIndexDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
private val tokenInfoRepository: TokenInfoRepository = mockk(relaxUnitFun = true)
private val database: TxHistoryDatabase = mockk()
private val repository = DefaultExpressHistoryRepository(
exchangeApi = exchangeApi,
onrampApi = onrampApi,
expressHistoryDao = expressHistoryDao,
historyIndexDao = historyIndexDao,
expressSyncStateDao = expressSyncStateDao,
tokenInfoRepository = tokenInfoRepository,
database = database,
appScope = TestAppCoroutineScope(),
)
@BeforeEach
fun setup() {
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao, tokenInfoRepository)
clearMocks(exchangeApi, onrampApi, expressHistoryDao, historyIndexDao, expressSyncStateDao, tokenInfoRepository)
// Run the withTransaction block inline so the DAO writes inside it actually happen and can be verified.
mockkStatic("androidx.room.RoomDatabaseKt")
val block = slot<suspend () -> Any?>()
coEvery { database.withTransaction(capture(block)) } coAnswers { block.captured.invoke() }
}
@AfterEach
fun tearDown() {
unmockkStatic("androidx.room.RoomDatabaseKt")
}
// region exchange history
@ -74,7 +98,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
@Test
@ -130,7 +154,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
// endregion
@ -155,7 +179,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -176,7 +200,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -206,10 +230,11 @@ internal class DefaultExpressHistoryRepositoryTest {
val item = createExchangeItem()
// WHEN
repository.storeExchanges(ownerAddress = ADDRESS, items = listOf(item))
repository.storeExchanges(items = listOf(item))
// THEN
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(item.toEntity()!!.toHistoryIndexEntities()) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(
setOf(
@ -226,10 +251,11 @@ internal class DefaultExpressHistoryRepositoryTest {
val item = createOnrampItem()
// WHEN
repository.storeOnramps(ownerAddress = ADDRESS, items = listOf(item))
repository.storeOnramps(items = listOf(item))
// THEN
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(listOf(item.toEntity().toHistoryIndexEntity())) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(setOf(ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract")))
}
@ -238,12 +264,27 @@ internal class DefaultExpressHistoryRepositoryTest {
@Test
fun `GIVEN empty items WHEN store THEN does nothing`() = runTest {
// WHEN
repository.storeExchanges(ownerAddress = ADDRESS, items = emptyList())
repository.storeOnramps(ownerAddress = ADDRESS, items = emptyList())
repository.storeExchanges(items = emptyList())
repository.storeOnramps(items = emptyList())
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}
@Test
fun `GIVEN exchange with null fromAddress WHEN storeExchanges THEN it is skipped`() = runTest {
// GIVEN
val item = createExchangeItem().copy(fromAddress = null)
// WHEN
repository.storeExchanges(items = listOf(item))
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}

View file

@ -89,7 +89,6 @@ internal class ExpressTxHistoryConverterTest {
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",
@ -131,7 +130,6 @@ internal class ExpressTxHistoryConverterTest {
status: String = "finished",
) = ExpressOnrampEntity(
txId = "onramp-1",
ownerAddress = "owner",
providerId = "provider",
payoutAddress = "owner",
status = status,

View file

@ -0,0 +1,97 @@
package com.tangem.data.txhistory.repository.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import io.mockk.every
import io.mockk.mockk
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Verifies the indexing rules that build [HistoryIndexEntity] rows: a swap is indexed under both its from- and
* into the sort time.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class HistoryIndexConverterTest {
@Test
fun `GIVEN swap with distinct from and payout WHEN indexed THEN one row per address`() {
// Arrange
val entity = exchange(txId = "tx1", from = "addrA", payout = "addrB", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrA"),
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrB"),
)
}
@Test
fun `GIVEN swap with equal from and payout WHEN indexed THEN de-duplicated to one row`() {
// Arrange
val entity = exchange(txId = "tx1", from = "same", payout = "same", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "same"))
}
@Test
fun `GIVEN onramp WHEN indexed THEN single row under payout address`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = CREATED_AT)
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row).isEqualTo(indexRow(HistoryIndexEntity.Type.ONRAMP, "tx2", "addrP"))
}
@Test
fun `GIVEN ISO-8601 created_at WHEN indexed THEN parsed into sort time millis`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = "2026-07-03T10:00:00.000Z")
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row.sortTimeMillis).isEqualTo(DateTime.parse("2026-07-03T10:00:00.000Z").millis)
}
private fun exchange(txId: String, from: String, payout: String, createdAt: String) =
mockk<ExpressExchangeEntity> {
every { this@mockk.txId } returns txId
every { fromAddress } returns from
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun onramp(txId: String, payout: String, createdAt: String) =
mockk<ExpressOnrampEntity> {
every { this@mockk.txId } returns txId
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun indexRow(type: HistoryIndexEntity.Type, txId: String, address: String) = HistoryIndexEntity(
type = type.value,
entityId = txId,
address = address,
sortTimeMillis = DateTime.parse(CREATED_AT).millis,
)
private companion object {
const val CREATED_AT = "2026-07-03T10:00:00.000Z"
}
}

View file

@ -88,7 +88,6 @@ internal class ExpressTransactionAssetFactoryTest {
/** A swap from a native coin (empty contract) to an Ethereum token. */
private fun coinToTokenSwap() = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",