Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-17 18:19:21 +04:00
parent 9299a2e8d0
commit 9065b5b0ee
18 changed files with 800 additions and 2 deletions

View file

@ -21,6 +21,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.express.models)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)

View file

@ -0,0 +1,30 @@
package com.tangem.data.common.converter
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.utils.converter.Converter
/**
* Maps a persisted [ExpressProviderEntity] into the domain [ExpressProvider].
*/
class ExpressProviderConverter : Converter<ExpressProviderEntity, ExpressProvider> {
override fun convert(value: ExpressProviderEntity): ExpressProvider {
return ExpressProvider(
providerId = value.id,
rateTypes = emptyList(),
name = value.name,
type = value.type.toExpressProviderType(),
imageLarge = value.imageLarge,
termsOfUse = value.termsOfUse,
privacyPolicy = value.privacyPolicy,
isRecommended = value.isRecommended,
slippage = value.slippage?.toBigDecimalOrNull(),
isExchangeOnlyWithinSingleAddress = value.isExchangeOnlyWithinSingleAddress,
isExtraIdSupported = value.isExtraIdSupported,
)
}
private fun String.toExpressProviderType(): ExpressProviderType = ExpressProviderType.valueOf(this)
}

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.account)

View file

@ -4,6 +4,8 @@ import androidx.annotation.VisibleForTesting
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTrigger
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes
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
@ -12,12 +14,14 @@ import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.flow.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
private val expressRepository: ExpressRepository,
private val getWalletsUseCase: GetWalletsUseCase,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
@ -26,6 +30,9 @@ 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())
}
@ -50,7 +57,7 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger?
.onEach { }
.onEach { wallet -> loadExpressProviders(wallet) }
.launchIn(this)
walletsFlow
@ -71,6 +78,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.collect {}
}
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 Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }

View file

@ -1,9 +1,18 @@
package com.tangem.data.txhistory.repository
import com.tangem.data.common.cache.CacheRegistry
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.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
@ -17,18 +26,96 @@ import com.tangem.pagination.BatchListSource
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.ISODateTimeFormat
import javax.inject.Inject
internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val expressHistoryDao: ExpressHistoryDao,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : TxHistoryRepositoryV2 {
private val sdkPageConverter = SdkPageConverter()
private val expressProviderConverter = ExpressProviderConverter()
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
override fun getExpressHistory(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fromCreatedAtMillis: Long,
): Flow<List<ExpressTx>> = flow {
val network = currency.network
val rawNetwork = network.rawId
val contract = (currency as? CryptoCurrency.Token)?.contractAddress
?: ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
// bound filters the window directly in SQL. Generated in the same UTC/'Z' shape as stored values.
val fromCreatedAtIso = DateTime(fromCreatedAtMillis, DateTimeZone.UTC)
.toString(ISODateTimeFormat.dateTimeNoMillis())
val address = walletManagersFacade.getDefaultAddress(userWalletId, network).orEmpty()
val flow = combine(
flow = expressHistoryDao.observeOutgoingSwaps(
ownerAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow2 = expressHistoryDao.observeIncomingSwaps(
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow3 = expressHistoryDao.observeIncomingOnramps(
ownerAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
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 }
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,

View file

@ -0,0 +1,108 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
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.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
import java.math.BigDecimal
/**
* Maps an exchange entity into a swap [ExpressTx.Swap].
*
* Direction is driven by [Input.isOutgoing]: outgoing views the swap's `from` side (pay-in, joined to
* on-chain by [ExpressExchangeEntity.payinHash]); incoming views the `to` side (payout, joined by
* [ExpressExchangeEntity.payoutHash]).
*/
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
tx = convertExchangeTransaction(value.entity, value.provider),
isOutgoing = value.isOutgoing,
txInfo = null,
)
data class Input(
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
)
}
internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input, ExpressTx.Onramp> {
override fun convert(value: Input): ExpressTx.Onramp {
val entity = value.entity
return ExpressTx.Onramp(
tx = OnrampTransaction(
txId = entity.txId,
status = ExpressOnrampStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = value.provider,
payoutHash = entity.payoutHash,
fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toBigDecimalOrZero(),
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(),
decimals = entity.to.decimals,
),
),
txInfo = null,
)
}
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
}
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = provider,
payinHash = entity.payinHash,
payoutHash = entity.payoutHash,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
decimals = entity.from.decimals,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
decimals = entity.to.decimals,
),
)
}
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
/**
* Active (non-terminal) RAW status values passed to the DAO `observe` queries so in-progress deals
* stay visible beyond the time window. Derived from [ExpressExchangeStatus.isTerminal] /
* [ExpressOnrampStatus.isTerminal] so the query set and the typed terminal classification never drift.
*/
internal object ExpressStatusMapper {
val activeExchangeStatuses: List<String> =
ExpressExchangeStatus.entries.filterNot { it.isTerminal }.map { it.raw }
val activeOnrampStatuses: List<String> =
ExpressOnrampStatus.entries.filterNot { it.isTerminal }.map { it.raw }
}

View file

@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
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.txhistory.fetcher.TxHistoryFetchTrigger
@ -13,6 +14,7 @@ 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
@ -26,13 +28,15 @@ internal class DefaultAppTxHistoryFetcherTest {
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
private val expressRepository: ExpressRepository = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory)
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository)
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
coEvery { expressRepository.getProviders(any(), any()) } returns emptyList()
}
@Test
@ -142,6 +146,71 @@ internal class DefaultAppTxHistoryFetcherTest {
assertThat(utils.fetcherScope.coroutineContext.job.isActive).isFalse()
}
@Test
fun `loads express providers when selected wallet is multi-currency`() = 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)
// Act
createFetcher(utils)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
}
@Test
fun `loads express providers only once per wallet`() = 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()) }
}
@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)
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())
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers.keys).containsExactly(WALLET_ID_1)
}
private fun TestScope.createUtils(): DefaultTxHistoryFetcherUtils = DefaultTxHistoryFetcherUtils(
appScope = TestAppCoroutineScope(testScope = this),
analyticsEventHandler = mockk(relaxed = true),
@ -150,6 +219,7 @@ internal class DefaultAppTxHistoryFetcherTest {
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
utils = utils,
expressRepository = expressRepository,
getWalletsUseCase = getWalletsUseCase,
selectedWalletUseCase = selectedWalletUseCase,
walletTxHistoryFetcherFactory = walletFetcherFactory,

View file

@ -0,0 +1,158 @@
package com.tangem.data.txhistory.repository.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.tokens.model.AmountType
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressTxHistoryConverterTest {
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
@Test
fun `GIVEN exchange entity WHEN toOutgoingSwap THEN outgoing and matched by payin hash`() {
// Arrange
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout", status = "waiting")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
// Assert
assertThat(swap.isOutgoing).isTrue()
assertThat(swap.matchHash).isEqualTo("payin")
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"))
}
@Test
fun `GIVEN exchange entity WHEN toIncomingSwap THEN incoming and matched by payout hash`() {
// Arrange
val entity = createExchangeEntity(payinHash = "payin", payoutHash = "payout")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = false))
// Assert
assertThat(swap.isOutgoing).isFalse()
assertThat(swap.matchHash).isEqualTo("payout")
}
@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")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
// Assert
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
}
@Test
fun `GIVEN onramp entity WHEN toOnramp THEN matched by payout hash with fiat from-leg`() {
// Arrange
val entity = createOnrampEntity(payoutHash = "payout", status = "finished")
// Act
val onramp = onrampConverter.convert(ExpressOnrampConverter.Input(entity, provider = null))
// Assert
assertThat(onramp.matchHash).isEqualTo("payout")
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.decimals).isEqualTo(2)
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
}
private fun createExchangeEntity(
payinHash: String? = "payin",
payoutHash: String? = "payout",
status: String = "waiting",
toAmount: String = "0.001",
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",
payinExtraId = null,
payoutAddress = "payout-addr",
refundAddress = null,
refundExtraId = null,
rateType = "float",
status = status,
externalTxId = null,
externalTxUrl = "https://ex.url",
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = null,
refundContractAddress = null,
createdAt = CREATED_AT,
updatedAt = CREATED_AT,
payTill = null,
averageDuration = null,
from = ExpressExchangeEntity.AssetEmbedded(
contractAddress = "",
network = "ethereum",
decimals = 18,
amount = "1.5",
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
contractAddress = "0xtoken",
network = "bitcoin",
decimals = 8,
amount = toAmount,
actualAmount = toActualAmount,
),
)
private fun createOnrampEntity(
payoutHash: String? = "payout",
status: String = "finished",
) = ExpressOnrampEntity(
txId = "onramp-1",
ownerAddress = "owner",
providerId = "provider",
payoutAddress = "owner",
status = status,
failReason = null,
externalTxId = null,
externalTxUrl = null,
payoutHash = payoutHash,
createdAt = CREATED_AT,
updatedAt = CREATED_AT,
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = "0xtoken",
network = "ethereum",
decimals = 18,
amount = "0.5",
actualAmount = null,
),
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val CREATED_AT = "2026-06-01T00:00:00Z"
}
}

View file

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

View file

@ -0,0 +1,26 @@
package com.tangem.domain.express.models
/**
* An express exchange (swap) operation, independent of how it is presented in the transaction history.
*
* Minimal set for now; extend as more of the express deal is needed.
*
* @property txId The express operation id.
* @property status The current exchange status.
* @property provider The provider behind the deal; `null` if not resolved.
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
* @property fromAsset The asset sent.
* @property toAsset The asset received.
*/
data class ExchangeTransaction(
val txId: String,
val status: ExpressExchangeStatus,
val createdAtMillis: Long,
val provider: ExpressProvider?,
val payinHash: String?,
val payoutHash: String?,
val fromAsset: ExpressTransactionAsset,
val toAsset: ExpressTransactionAsset,
)

View file

@ -0,0 +1,59 @@
package com.tangem.domain.express.models
/**
* Express exchange (swap) transaction status.
*
* [raw] is the exact value received from / persisted by the backend. Responses and the local DB keep the
* status as a raw string (so a brand-new backend value never breaks parsing); this enum is the typed view
* used after reading it back.
*/
enum class ExpressExchangeStatus(val raw: String) {
Preview("preview"),
Created("created"),
ExchangeTxSent("exchange-tx-sent"),
Waiting("waiting"),
WaitingTxHash("waiting-tx-hash"),
Confirming("confirming"),
Exchanging("exchanging"),
Sending("sending"),
Finished("finished"),
Failed("failed"),
TxFailed("tx-failed"),
Refunded("refunded"),
Verifying("verifying"),
Expired("expired"),
Paused("paused"),
Unknown("unknown"),
;
/**
* Whether the deal has reached a final state where no further status changes are expected.
* Terminal: [Expired], [Unknown], [Refunded], [Finished], [TxFailed], [Paused]. Note that [Failed]
* (unlike [TxFailed]) is NOT terminal here.
*/
val isTerminal: Boolean
get() = when (this) {
Expired,
Unknown,
Refunded,
Finished,
TxFailed,
Paused,
-> true
Preview,
Created,
ExchangeTxSent,
Waiting,
WaitingTxHash,
Confirming,
Exchanging,
Sending,
Failed,
Verifying,
-> false
}
companion object {
fun fromRaw(raw: String): ExpressExchangeStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.domain.express.models
/**
* Express onramp transaction status.
*
* [raw] is the exact value received from / persisted by the backend. Responses and the local DB keep the
* status as a raw string (so a brand-new backend value never breaks parsing); this enum is the typed view
* used after reading it back.
*/
enum class ExpressOnrampStatus(val raw: String) {
Created("created"),
Expired("expired"),
WaitingForPayment("waiting-for-payment"),
PaymentProcessing("payment-processing"),
Verifying("verifying"),
Failed("failed"),
Paid("paid"),
Sending("sending"),
Finished("finished"),
Paused("paused"),
/**
* Client-side fallback for an unrecognized status. The backend does NOT currently send such a value
* it is used by [fromRaw] when the raw string matches none of the known statuses.
*/
Unknown("unknown"),
;
/**
* Whether the deal has reached a final state where no further status changes are expected.
* Terminal: [Expired], [Failed], [Finished], [Paused] (and the client fallback [Unknown]).
*/
val isTerminal: Boolean
get() = when (this) {
Expired,
Failed,
Finished,
Paused,
Unknown,
-> true
Created,
WaitingForPayment,
PaymentProcessing,
Verifying,
Paid,
Sending,
-> false
}
companion object {
fun fromRaw(raw: String): ExpressOnrampStatus = entries.firstOrNull { it.raw == raw } ?: Unknown
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.express.models
import java.math.BigDecimal
/**
* A crypto asset leg of an express operation: which asset and how much of it moved.
*
* @property id The asset identifier (network id + contract address).
* @property amount Human-readable amount (already scaled by [decimals]).
* @property decimals The asset's decimals.
*/
data class ExpressTransactionAsset(
val id: ExpressAsset.ID,
val amount: BigDecimal,
val decimals: Int,
)

View file

@ -0,0 +1,28 @@
package com.tangem.domain.express.models
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
/**
* An express onramp operation, independent of how it is presented in the transaction history.
*
* Minimal set for now; extend as more of the express deal is needed.
*
* @property txId The express operation id.
* @property status The current onramp status.
* @property provider The provider behind the deal; `null` if not resolved.
* @property payoutHash On-chain hash of the payout (received) leg, if known.
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
*/
data class OnrampTransaction(
val txId: String,
val status: ExpressOnrampStatus,
val createdAtMillis: Long,
val provider: ExpressProvider?,
val payoutHash: String?,
/** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount,
val toAsset: ExpressTransactionAsset,
)

View file

@ -13,10 +13,12 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.core)
api(projects.domain.express.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
/** Project - Other */
implementation(projects.core.utils)

View file

@ -0,0 +1,119 @@
package com.tangem.domain.txhistory.model
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.network.TxInfo
/**
* A single row of the unified transaction history shown on the token-details screen.
*
* Two origins are merged into one timeline:
* - [OnChainTx] a blockchain transaction (the pagination backbone).
* - [ExpressTx] an exchange (swap) or onramp operation persisted by the express sync. It may be a
* standalone live row (no on-chain leg loaded yet) or enriched with its matched on-chain leg.
*/
sealed interface TxHistoryInfo {
/**
* Stable identity of the row: the express `txId` for [ExpressTx], the on-chain [identityKey]
* for [OnChainTx].
*/
val txId: String
/** Position of the row in the timeline (ms since epoch), used for the global timestamp-DESC sort. */
val timestampMillis: Long
}
/**
* A blockchain transaction row. Sealed over its origin so new on-chain sources can be added without
* touching the merge/UI seam: today only [BSDK] (the blockchain-SDK history that backs pagination);
* future sources (e.g. TangemPay, Gateway) join as sibling subtypes.
*/
sealed interface OnChainTx : TxHistoryInfo {
/** On-chain tx surfaced by the blockchain SDK — the pagination backbone, wrapping a raw [TxInfo]. */
data class BSDK(val txInfo: TxInfo) : OnChainTx {
override val txId: String get() = txInfo.identityKey()
override val timestampMillis: Long get() = txInfo.timestampInMillis
}
// todo txHistory next step
/*data class TangemPay(val txInfo: TangemPayTxHistoryItem) : OnChainTx {
override val txId: String get() = txInfo.id
override val timestampMillis: Long get() = txInfo.date.millis
}*/
// todo txHistory next step
/*data class Gateway() : OnChainTx {
override val txId: String get() = txInfo.id
override val timestampMillis: Long get() = txInfo.date.millis
}*/
}
/**
* Cross-batch identity of a tx: `txHash` alone is not enough because gasless flows surface several
* events under the same on-chain hash (e.g. `GaslessFee` + `Transfer`). Pinning the [TxInfo.type]
* keeps those legitimate sibling events apart while still collapsing the same event seen twice
* e.g. an Unconfirmed copy injected via `addRecentTransactions` and a Confirmed copy that arrives
* in a later API batch.
*
* Single source of truth for tx identity, used both by [OnChainTx.BSDK.txId] and the on-chain de-duplication
* in the history pipeline.
*/
fun TxInfo.identityKey(): String = "$txHash|$type"
/**
* A history row backed by an express operation. It is a thin wrapper over the standalone express
* model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns:
* the matched on-chain leg ([txInfo]) and, for swaps, which side the viewed currency is on
* ([Swap.isOutgoing]). Everything intrinsic to the deal is delegated to the wrapped model.
*/
sealed interface ExpressTx : TxHistoryInfo {
override val txId: String
/** Creation timestamp (ms). Used as the row position while there is no matched on-chain leg. */
val createdAtMillis: Long
/**
* Hash that joins this express op to its on-chain leg:
* `payinHash` for outgoing (this currency is the swap's `from`), `payoutHash` otherwise.
*/
val matchHash: String?
/** Matched on-chain leg; `null` while it has not loaded yet (standalone live row). */
val txInfo: OnChainTx?
/** Provider behind this op, resolved from the local providers table by `providerId`; `null` if unknown. */
val provider: ExpressProvider?
/** Whether the deal reached a final state. Delegates to the wrapped model's typed status. */
val isTerminal: Boolean
override val timestampMillis: Long get() = txInfo?.timestampMillis ?: createdAtMillis
data class Swap(
val tx: ExchangeTransaction,
/** Whether the viewed currency is the swap's `from` (pay-in) side. */
val isOutgoing: Boolean,
override val txInfo: OnChainTx?,
) : ExpressTx {
override val txId: String get() = tx.txId
override val createdAtMillis: Long get() = tx.createdAtMillis
override val matchHash: String? get() = if (isOutgoing) tx.payinHash else tx.payoutHash
override val provider: ExpressProvider? get() = tx.provider
override val isTerminal: Boolean get() = tx.status.isTerminal
}
data class Onramp(
val tx: OnrampTransaction,
override val txInfo: OnChainTx?,
) : ExpressTx {
override val txId: String get() = tx.txId
override val createdAtMillis: Long get() = tx.createdAtMillis
override val matchHash: String? get() = tx.payoutHash
override val provider: ExpressProvider? get() = tx.provider
override val isTerminal: Boolean get() = tx.status.isTerminal
}
}

View file

@ -1,9 +1,26 @@
package com.tangem.domain.txhistory.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import kotlinx.coroutines.flow.Flow
interface TxHistoryRepositoryV2 {
fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow
/**
* Reactive stream of express (swap & onramp) operations relevant to [currency] of wallet [userWalletId].
*
* (the oldest loaded on-chain timestamp; `0` = no lower bound) to cap the working set; in-progress
* operations are always included regardless of the bound. Re-emits live as the express DB is updated.
*/
fun getExpressHistory(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fromCreatedAtMillis: Long,
): Flow<List<ExpressTx>>
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
@ -224,6 +225,12 @@ internal class TxHistoryListManagerTest {
batchFetcher = fetcher,
).toBatchFlow().also { batchFlow = it }
override fun getExpressHistory(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fromCreatedAtMillis: Long,
) = emptyFlow<List<ExpressTx>>()
fun loadedItemsCount(): Int = batchFlow.state.value.data.sumOf { batch -> batch.data.items.size }
fun status(): PaginationStatus<*> = batchFlow.state.value.status