Updated on 2026-08-14
This commit is contained in:
parent
9299a2e8d0
commit
9065b5b0ee
18 changed files with 800 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ plugins {
|
|||
dependencies {
|
||||
implementation(deps.moshi.adapters)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(projects.domain.tokens.models)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue