Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-23 17:15:22 +04:00
parent 68f451d926
commit 8643eb4a90
10 changed files with 256 additions and 30 deletions

View file

@ -5,9 +5,14 @@ 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.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
@ -36,6 +41,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val expressHistoryDao: ExpressHistoryDao,
private val expressTransactionAssetFactory: ExpressTransactionAssetFactory,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : TxHistoryRepositoryV2 {
@ -85,37 +91,67 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
).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 }
buildExpressHistory(
userWalletId = userWalletId,
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
providers = providers,
)
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
private suspend fun buildExpressHistory(
userWalletId: UserWalletId,
outgoingSwaps: List<ExpressExchangeEntity>,
incomingSwaps: List<ExpressExchangeEntity>,
onramps: List<ExpressOnrampEntity>,
providers: Map<String, ExpressProviderEntity>,
): List<ExpressTx> {
val currencies = expressTransactionAssetFactory.create(
userWalletId = userWalletId,
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
)
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
return buildList {
outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
toCurrency = currencies[entity.to.toAssetId()],
)
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
}
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,

View file

@ -9,6 +9,7 @@ import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
@ -26,7 +27,7 @@ import java.math.BigDecimal
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
tx = convertExchangeTransaction(value.entity, value.provider),
tx = convertExchangeTransaction(value),
isOutgoing = value.isOutgoing,
txInfo = null,
)
@ -35,6 +36,8 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
val fromCurrency: CryptoCurrency? = null,
val toCurrency: CryptoCurrency? = null,
)
}
@ -59,32 +62,40 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
),
txInfo = null,
)
}
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
data class Input(
val entity: ExpressOnrampEntity,
val provider: ExpressProvider?,
val toCurrency: CryptoCurrency? = null,
)
}
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
val entity = value.entity
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = provider,
provider = value.provider,
payinHash = entity.payinHash,
payoutHash = entity.payoutHash,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
decimals = entity.from.decimals,
cryptoCurrency = value.fromCurrency,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
)
}

View file

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

View file

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

View file

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

View file

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