diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index b676b16a02..696f498317 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -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) diff --git a/data/common/src/main/kotlin/com/tangem/data/common/converter/ExpressProviderConverter.kt b/data/common/src/main/kotlin/com/tangem/data/common/converter/ExpressProviderConverter.kt new file mode 100644 index 0000000000..14fc732d55 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/converter/ExpressProviderConverter.kt @@ -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 { + + 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) +} \ No newline at end of file diff --git a/data/txhistory/build.gradle.kts b/data/txhistory/build.gradle.kts index 0bb1450c46..0320d9912c 100644 --- a/data/txhistory/build.gradle.kts +++ b/data/txhistory/build.gradle.kts @@ -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) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt index 1cc9417a0a..c4e4e208a3 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt @@ -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() + /** Wallets whose express providers were already loaded — to load them at most once per wallet. */ + private val providersLoadedWallets = mutableSetOf() + 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>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() } private fun Set.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt index 6fcc14bd8e..079d385fa7 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -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> = 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 { + 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, diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt new file mode 100644 index 0000000000..ce51c41197 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -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 { + + 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 { + + 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 = + ExpressExchangeStatus.entries.filterNot { it.isTerminal }.map { it.raw } + + val activeOnrampStatuses: List = + ExpressOnrampStatus.entries.filterNot { it.isTerminal }.map { it.raw } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt index 8b69c2d793..886dbb1c83 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt @@ -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(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(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()) + every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow + val wallet = mockk(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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt new file mode 100644 index 0000000000..81247ffdda --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt @@ -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" + } +} \ No newline at end of file diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts index 701087a4b1..22efcd57bd 100644 --- a/domain/express/models/build.gradle.kts +++ b/domain/express/models/build.gradle.kts @@ -7,4 +7,5 @@ plugins { dependencies { implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(projects.domain.tokens.models) } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt new file mode 100644 index 0000000000..616c94d1bd --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExchangeTransaction.kt @@ -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, +) \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt new file mode 100644 index 0000000000..ab7800d2df --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressExchangeStatus.kt @@ -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 + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressOnrampStatus.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressOnrampStatus.kt new file mode 100644 index 0000000000..5449f1b35c --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressOnrampStatus.kt @@ -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 + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt new file mode 100644 index 0000000000..954db1d1c6 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt @@ -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, +) \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt new file mode 100644 index 0000000000..4940b3d055 --- /dev/null +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt @@ -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, +) \ No newline at end of file diff --git a/domain/txhistory/build.gradle.kts b/domain/txhistory/build.gradle.kts index 4e4655573b..ff40ff4913 100644 --- a/domain/txhistory/build.gradle.kts +++ b/domain/txhistory/build.gradle.kts @@ -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) diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt new file mode 100644 index 0000000000..73a0b7d670 --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt @@ -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 + } +} \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt index ae9ea875ea..624ff8eb2a 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepositoryV2.kt @@ -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> } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt index 3f8a86d59a..801fbd968a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt @@ -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>() + fun loadedItemsCount(): Int = batchFlow.state.value.data.sumOf { batch -> batch.data.items.size } fun status(): PaginationStatus<*> = batchFlow.state.value.status