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,13 +91,40 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
).distinctUntilChanged(),
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
buildList<ExpressTx> {
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))
}
@ -100,21 +133,24 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
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.providerId.expressProvider())
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 }
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
}
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(

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(),
)
}

View file

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

View file

@ -1,5 +1,6 @@
package com.tangem.domain.express.models
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
/**
@ -8,9 +9,12 @@ import java.math.BigDecimal
* @property id The asset identifier (network id + contract address).
* @property amount Human-readable amount (already scaled by [decimals]).
* @property decimals The asset's decimals.
* @property cryptoCurrency The portfolio [CryptoCurrency] this asset was resolved to (matched by network id +
* contract address across all accounts). `null` when no portfolio currency matched and no fallback could be built.
*/
data class ExpressTransactionAsset(
val id: ExpressAsset.ID,
val amount: BigDecimal,
val decimals: Int,
val cryptoCurrency: CryptoCurrency? = null,
)

View file

@ -0,0 +1,50 @@
package com.tangem.domain.models.network
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Domain mirror of the blockchain SDK `Amount`, kept [Serializable] so it can be carried inside the serializable
* [TxInfo] graph (the SDK `Amount` is not serializable and pulls in blockchain-specific types).
*
* Holds a monetary value together with the metadata needed to display it. Compared to the SDK model it drops
* `maxValue` (irrelevant outside of "send" flows) and keeps only the currency identity on [SdkAmountType].
*
* @property currencySymbol display symbol of the currency (e.g. `ETH`, `USDT`)
* @property value amount value; `null` when the value is unknown
* @property decimals number of decimals of the currency
* @property type kind of currency the amount is denominated in
*/
@Serializable
data class SdkAmount(
val currencySymbol: String,
val value: SerializedBigDecimal? = null,
val decimals: Int,
val type: SdkAmountType = SdkAmountType.Coin,
)
/** Kind of currency an [SdkAmount] is denominated in. Mirrors the SDK `AmountType`. */
@Serializable
sealed interface SdkAmountType {
/** Native coin of the blockchain. */
@Serializable
data object Coin : SdkAmountType
/** Native coin used as a reserve currency for fee calculation (e.g. Algorand). */
@Serializable
data object Reserve : SdkAmountType
/** A resource that can be spent to pay the fee (e.g. Mana on Koinos). */
@Serializable
data class FeeResource(val name: String? = null) : SdkAmountType
/**
* A token of the blockchain.
*
* @property contractAddress token contract address
* @property id backend currency id, when known
*/
@Serializable
data class Token(val contractAddress: String, val id: String? = null) : SdkAmountType
}

View file

@ -15,6 +15,7 @@ import kotlinx.serialization.Serializable
* @property status transaction status
* @property type transaction type
* @property amount transaction amount
* @property fee transaction fee
*/
@Serializable
data class TxInfo(
@ -27,6 +28,7 @@ data class TxInfo(
val status: TransactionStatus,
val type: TransactionType,
val amount: SerializedBigDecimal,
val fee: SdkAmount? = null,
) {
/** Destination type*/