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 079d385fa7..704a77c58c 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 @@ -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 { - 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, + incomingSwaps: List, + onramps: List, + providers: Map, + ): List { + 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, 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 index ce51c41197..5f07b6dc5a 100644 --- 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 @@ -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 { 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 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, + incomingSwaps: List, + onramps: List, + ): Map { + 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.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) \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt new file mode 100644 index 0000000000..e89a1acb17 --- /dev/null +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt @@ -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, + ) +} \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index 9832632e12..c69bd21cc6 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -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) { diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt index 1247fce030..07e601bc3e 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt @@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter( }, type = getTransactionType(value), amount = amount, + fee = value.fee?.amount?.toDomain(), ) } diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts index 22efcd57bd..a63174eff0 100644 --- a/domain/express/models/build.gradle.kts +++ b/domain/express/models/build.gradle.kts @@ -7,5 +7,6 @@ plugins { dependencies { implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(projects.domain.models) 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/ExpressTransactionAsset.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt index 954db1d1c6..bce9d73383 100644 --- 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 @@ -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, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt new file mode 100644 index 0000000000..4f2bb40fab --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt @@ -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 +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt index 2e0c0b37b7..83e0036176 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt @@ -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*/