Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-12 13:20:39 +05:00
parent 89b3e67f64
commit 296df28f0a
31 changed files with 958 additions and 0 deletions

View file

@ -102,6 +102,8 @@ dependencies {
implementation(projects.domain.walletConnect)
implementation(projects.domain.markets)
implementation(projects.domain.manageTokens)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.onramp)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
@ -144,6 +146,7 @@ dependencies {
implementation(projects.data.walletConnect)
implementation(projects.data.markets)
implementation(projects.data.manageTokens)
implementation(projects.data.nft)
implementation(projects.data.onramp)
/** Features */

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.domain.staking.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain)

View file

@ -3,6 +3,8 @@ package com.tangem.datasource.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.common.adapter.BigDecimalAdapter
import com.tangem.datasource.api.common.adapter.DateTimeAdapter
@ -45,6 +47,18 @@ class MoshiModule {
.withSubtype(NetworkStatusDM.Verified::class.java, "amounts")
.withSubtype(NetworkStatusDM.NoAccount::class.java, "amount_to_create_account"),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")
.withSubtype(NFTCollection.Identifier.TON::class.java, "ton")
.withDefaultValue(NFTCollection.Identifier.Unknown),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc")
.withSubtype(NFTAsset.Identifier.EVM::class.java, "evm")
.withSubtype(NFTAsset.Identifier.TON::class.java, "ton")
.withDefaultValue(NFTAsset.Identifier.Unknown),
)
.addLast(KotlinJsonAdapterFactory())
.addStakeKitEnumFallbackAdapters()
.build()

View file

@ -36,6 +36,8 @@ class EnvironmentConfigModel(
@Json(name = "bittensorOnfinalityKey") val bittensorOnfinalityKey: String?,
@Json(name = "koinosProApiKey") val koinosProApiKey: String?,
@Json(name = "alephiumTangemApiKey") val alephiumTangemApiKey: String?,
@Json(name = "moralisApiKey") val moralisApiKey: String?,
@Json(name = "nftScanApiKey") val nftScanApiKey: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -0,0 +1,49 @@
package com.tangem.datasource.local.nft
import androidx.datastore.core.DataStore
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
internal class DefaultNFTPersistenceStore(
private val collectionsPersistenceStore: DataStore<List<NFTCollection>>,
private val pricesPersistenceStore: DataStore<Map<NFTAsset.Identifier, NFTAsset.SalePrice>>,
) : NFTPersistenceStore {
override fun getCollections(): Flow<List<NFTCollection>> = collectionsPersistenceStore.data
override suspend fun getCollectionsSync(): List<NFTCollection>? = collectionsPersistenceStore
.data
.firstOrNull()
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?> =
collectionsPersistenceStore.data
.map { collection ->
collection
.firstOrNull { it.identifier == collectionId }
?.getAsset(assetId)
}
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTAsset.SalePrice?> = pricesPersistenceStore.data
.map { it[assetId] }
override suspend fun getSalePricesSync(): Map<NFTAsset.Identifier, NFTAsset.SalePrice>? = pricesPersistenceStore
.data
.firstOrNull()
override suspend fun saveCollections(collections: List<NFTCollection>) {
collectionsPersistenceStore.updateData {
collections
}
}
override suspend fun saveSalePrice(assetId: NFTAsset.Identifier, salePrice: NFTAsset.SalePrice) {
pricesPersistenceStore.updateData {
it.toMutableMap().apply { this[assetId] = salePrice }
}
}
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier) = assets.firstOrNull { it.identifier == assetId }
}

View file

@ -0,0 +1,111 @@
package com.tangem.datasource.local.nft
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.domain.tokens.model.Network
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
internal class DefaultNFTRuntimeStore(
private val network: Network,
private val collectionsRuntimeStore: RuntimeSharedStore<NFTCollections>,
private val pricesRuntimeStore: RuntimeSharedStore<Map<NFTAsset.Identifier, NFTSalePrice>>,
) : NFTRuntimeStore {
override suspend fun initialize(collections: NFTCollections, prices: Map<NFTAsset.Identifier, NFTSalePrice>) {
collectionsRuntimeStore.store(collections)
pricesRuntimeStore.store(prices)
}
override fun getCollections(): Flow<NFTCollections> = collectionsRuntimeStore
.get()
.combine(pricesRuntimeStore.get(), ::Pair)
.map {
val (collectionsData, prices) = it
collectionsData.mergeWithPrices(prices)
}
override suspend fun getCollectionsSync(): NFTCollections {
val collectionsData = collectionsRuntimeStore.getSyncOrNull()
val prices = pricesRuntimeStore.getSyncOrNull()
return collectionsData
?.mergeWithPrices(prices.orEmpty())
?: NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = null,
source = StatusSource.CACHE,
),
)
}
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset> =
collectionsRuntimeStore
.get()
.combine(getSalePrice(assetId), ::Pair)
.map {
val (collectionsData, price) = it
collectionsData
.getCollection(collectionId)
?.getAsset(assetId)
?.mergeWithPrice(price)
?: NFTAsset.Error(assetId)
}
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> = pricesRuntimeStore
.get()
.map { it[assetId] ?: NFTSalePrice.Empty(assetId) }
override suspend fun saveCollections(collections: NFTCollections) {
collectionsRuntimeStore.store(collections)
}
override suspend fun saveSalePrice(salePrice: NFTSalePrice) {
pricesRuntimeStore.update(emptyMap()) {
it.plus(salePrice.assetId to salePrice)
}
}
private fun NFTCollections.getCollection(collectionId: NFTCollection.Identifier): NFTCollection? =
(content as? NFTCollections.Content.Collections)
?.collections
?.firstOrNull { it.id == collectionId }
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? =
assets.firstOrNull { it.id == assetId }
private fun NFTCollections.mergeWithPrices(prices: Map<NFTAsset.Identifier, NFTSalePrice>): NFTCollections =
when (val content = this.content) {
is NFTCollections.Content.Collections -> {
this.copy(
content = content.mergeWithPrices(prices),
)
}
is NFTCollections.Content.Error -> this
}
private fun NFTCollections.Content.Collections.mergeWithPrices(prices: Map<NFTAsset.Identifier, NFTSalePrice>) =
copy(
collections = this.collections?.map { data ->
data.copy(
assets = data.assets.map { asset ->
asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id))
},
)
},
source = this.source,
)
private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = when (this) {
is NFTAsset.Error -> this
is NFTAsset.Value -> copy(
salePrice = price,
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.nft
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import kotlinx.coroutines.flow.Flow
interface NFTPersistenceStore {
fun getCollections(): Flow<List<NFTCollection>>
suspend fun getCollectionsSync(): List<NFTCollection>?
fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTAsset.SalePrice?>
suspend fun getSalePricesSync(): Map<NFTAsset.Identifier, NFTAsset.SalePrice>?
suspend fun saveCollections(collections: List<NFTCollection>)
suspend fun saveSalePrice(assetId: NFTAsset.Identifier, salePrice: NFTAsset.SalePrice)
}

View file

@ -0,0 +1,54 @@
package com.tangem.datasource.local.nft
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithCustomKeyTypes
import com.tangem.domain.tokens.model.Network
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NFTPersistenceStoreFactory @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatcherProvider,
) {
fun provide(network: Network): NFTPersistenceStore {
return DefaultNFTPersistenceStore(
collectionsPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<NFTCollection>(),
defaultValue = emptyList(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${network.name}_${network.derivationPath}_collections")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
pricesPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithCustomKeyTypes<NFTAsset.Identifier, NFTAsset.SalePrice>(),
defaultValue = emptyMap(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${network.name}_${network.derivationPath}_prices")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.nft
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import kotlinx.coroutines.flow.Flow
interface NFTRuntimeStore {
suspend fun initialize(collections: NFTCollections, prices: Map<NFTAsset.Identifier, NFTSalePrice>)
fun getCollections(): Flow<NFTCollections>
suspend fun getCollectionsSync(): NFTCollections
fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice?>
suspend fun saveCollections(collections: NFTCollections)
suspend fun saveSalePrice(salePrice: NFTSalePrice)
}

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.local.nft
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.tokens.model.Network
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NFTRuntimeStoreFactory @Inject constructor() {
fun provide(network: Network): NFTRuntimeStore = DefaultNFTRuntimeStore(
network = network,
collectionsRuntimeStore = RuntimeSharedStore(),
pricesRuntimeStore = RuntimeSharedStore(),
)
}

View file

@ -0,0 +1,55 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
class NFTSdkAssetConverter(
private val nftSdkAssetIdentifierConverter: NFTSdkAssetIdentifierConverter,
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
) : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
override fun convert(value: Pair<Network, SdkNFTAsset>): NFTAsset {
val (network, asset) = value
val assetId = nftSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = nftSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
return NFTAsset.Value(
id = assetId,
collectionId = collectionId,
network = network,
contractType = asset.contractType,
owner = asset.owner,
name = asset.name,
description = asset.description,
salePrice = asset.salePrice?.let {
NFTSalePrice.Value(
assetId = assetId,
value = it.value,
symbol = it.symbol,
source = StatusSource.CACHE,
)
} ?: NFTSalePrice.Empty(assetId = assetId),
rarity = asset.rarity?.let {
NFTAsset.Value.Rarity(
rank = it.rank,
label = it.label,
)
},
media = asset.media?.let {
NFTAsset.Value.Media(
url = it.url,
mimetype = it.mimetype,
)
},
traits = asset.traits.map {
NFTAsset.Value.Trait(
name = it.name,
value = it.value,
)
},
source = StatusSource.CACHE,
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
class NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier, NFTAsset.Identifier> {
override fun convert(value: SdkNFTAsset.Identifier): NFTAsset.Identifier = when (value) {
is SdkNFTAsset.Identifier.EVM -> NFTAsset.Identifier.EVM(
tokenId = value.tokenId,
tokenAddress = value.tokenAddress,
)
is SdkNFTAsset.Identifier.TON -> NFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
is SdkNFTAsset.Identifier.Unknown -> error("unknown asset id")
}
override fun convertBack(value: NFTAsset.Identifier): SdkNFTAsset.Identifier = when (value) {
is NFTAsset.Identifier.EVM -> SdkNFTAsset.Identifier.EVM(
tokenId = value.tokenId,
tokenAddress = value.tokenAddress,
)
is NFTAsset.Identifier.TON -> SdkNFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
class NFTSdkCollectionConverter(
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
private val nftSdkAssetConverter: NFTSdkAssetConverter,
) : Converter<Pair<Network, SdkNFTCollection>, NFTCollection> {
override fun convert(value: Pair<Network, SdkNFTCollection>): NFTCollection {
val (network, collection) = value
val collectionId = nftSdkCollectionIdentifierConverter.convert(collection.identifier)
return NFTCollection(
id = collectionId,
network = network,
name = collection.name,
description = collection.description,
logoUrl = collection.logoUrl,
count = collection.count,
assets = collection.assets.map { asset ->
nftSdkAssetConverter.convert(network to asset)
},
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
class NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Identifier, NFTCollection.Identifier> {
override fun convert(value: SdkNFTCollection.Identifier): NFTCollection.Identifier = when (value) {
is SdkNFTCollection.Identifier.EVM -> NFTCollection.Identifier.EVM(
tokenAddress = value.tokenAddress,
)
is SdkNFTCollection.Identifier.TON -> NFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
is SdkNFTCollection.Identifier.Unknown -> error("unknown collection id")
}
override fun convertBack(value: NFTCollection.Identifier): SdkNFTCollection.Identifier = when (value) {
is NFTCollection.Identifier.EVM -> SdkNFTCollection.Identifier.EVM(
tokenAddress = value.tokenAddress,
)
is NFTCollection.Identifier.TON -> SdkNFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
}
}

View file

@ -11,6 +11,10 @@ inline fun <reified T> mapWithStringKeyTypes(): ParameterizedType {
return Types.newParameterizedType(Map::class.java, String::class.java, T::class.java)
}
inline fun <reified K, reified T> mapWithCustomKeyTypes(): ParameterizedType {
return Types.newParameterizedType(Map::class.java, K::class.java, T::class.java)
}
inline fun <reified T> listTypes(): ParameterizedType {
return Types.newParameterizedType(List::class.java, T::class.java)
}

1
data/nft/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

51
data/nft/build.gradle.kts Normal file
View file

@ -0,0 +1,51 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.nft"
}
dependencies {
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Libs - Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
/** Libs - Tangem */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,227 @@
package com.tangem.data.nft
import arrow.core.Either
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
internal class DefaultNFTRepository @Inject constructor(
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory,
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory,
private val walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
) : NFTRepository {
private val scope = CoroutineScope(dispatchers.io + SupervisorJob())
private val jobs = mutableMapOf<Network, JobHolder>()
private val nftRuntimeStores = mutableMapOf<Network, NFTRuntimeStore>()
private val nftPersistenceStores = mutableMapOf<Network, NFTPersistenceStore>()
private val nftSdkAssetIdentifierConverter = NFTSdkAssetIdentifierConverter()
private val nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter()
private val nftSdkAssetConverter = NFTSdkAssetConverter(
nftSdkAssetIdentifierConverter = nftSdkAssetIdentifierConverter,
nftSdkCollectionIdentifierConverter = nftSdkCollectionIdentifierConverter,
)
private val collectionConverter = NFTSdkCollectionConverter(
nftSdkCollectionIdentifierConverter = nftSdkCollectionIdentifierConverter,
nftSdkAssetConverter = nftSdkAssetConverter,
)
override fun observeCollections(userWalletId: UserWalletId, networks: List<Network>): Flow<List<NFTCollections>> =
flow {
emitAll(
combine(
networks.map { getNFTRuntimeStore(it).getCollections() },
) { it.asList() },
)
}.onStart {
refreshCollections(userWalletId, networks)
}
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) {
networks.map { network ->
scope.launch {
expireCollections(network)
Either.catch {
walletManagersFacade.getNFTCollections(userWalletId, network)
}.onLeft {
saveFailedStateInRuntime(
network = network,
error = it,
)
}.onRight {
val mergedCollections = it.mergeWithStoredAssets(network)
saveCollectionsInRuntime(
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
network = network,
collections = mergedCollections,
)
}
}.saveIn(getJobHolder(network))
}.joinAll()
}
private suspend fun expireCollections(network: Network) {
val runtimeStore = getNFTRuntimeStore(network)
val expiredCollections = runtimeStore
.getCollectionsSync()
.let { collections ->
collections.copy(
content = when (val content = collections.content) {
is NFTCollections.Content.Collections -> content.copy(
source = StatusSource.CACHE,
)
is NFTCollections.Content.Error -> content
},
)
}
runtimeStore.saveCollections(expiredCollections)
}
private suspend fun saveCollectionsInRuntime(network: Network, collections: List<SdkNFTCollection>) {
getNFTRuntimeStore(network).saveCollections(
NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = collections.map { collection ->
collectionConverter.convert(network to collection)
},
source = StatusSource.ACTUAL,
),
),
)
}
private suspend fun saveFailedStateInRuntime(network: Network, error: Throwable) {
getNFTRuntimeStore(network).saveCollections(
NFTCollections(
network = network,
content = NFTCollections.Content.Error(
error = error,
),
),
)
}
private suspend fun saveCollectionsInPersistence(network: Network, collections: List<SdkNFTCollection>) {
getNFTPersistenceStore(network).saveCollections(collections)
}
private fun getJobHolder(network: Network): JobHolder = jobs[network] ?: run {
JobHolder().also {
jobs[network] = it
}
}
private fun getNFTPersistenceStore(network: Network): NFTPersistenceStore = nftPersistenceStores[network] ?: run {
nftPersistenceStoreFactory.provide(network).also {
nftPersistenceStores[network] = it
}
}
private suspend fun getNFTRuntimeStore(network: Network): NFTRuntimeStore = nftRuntimeStores[network] ?: run {
nftRuntimeStoreFactory.provide(network).also {
nftRuntimeStores[network] = it
val storedCollections = getStoredCollections(network)
val storedPrices = getStoredPrices(network)
it.initialize(
collections = storedCollections,
prices = storedPrices,
)
}
}
private suspend fun getStoredCollections(network: Network) = getNFTPersistenceStore(network)
.getCollectionsSync()
.let {
NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = it?.map { collection ->
collectionConverter.convert(network to collection)
},
source = StatusSource.CACHE,
),
)
}
private suspend fun getStoredPrices(network: Network) = getNFTPersistenceStore(network)
.getSalePricesSync()
.orEmpty()
.let { prices ->
prices
.mapKeys {
val (assetId, _) = it
nftSdkAssetIdentifierConverter.convert(assetId)
}
.mapValues {
val (assetId, price) = it
NFTSalePrice.Value(
assetId = assetId,
value = price.value,
symbol = price.symbol,
source = StatusSource.CACHE,
)
}
}
private suspend fun List<SdkNFTCollection>.mergeWithStoredAssets(network: Network): List<SdkNFTCollection> {
val storedCollections =
getNFTPersistenceStore(network)
.getCollectionsSync()
.orEmpty()
.associateBy(NFTCollection::identifier)
return this.map { updatedCollection ->
if (!storedCollections.containsKey(updatedCollection.identifier)) {
// if there is no collection in cache, then write a new one
updatedCollection
} else if (updatedCollection.assets.isNotEmpty()) {
// if there are assets in a new collection, then write this
updatedCollection
} else {
// otherwise, just update a stored collection fields
storedCollections[updatedCollection.identifier]
?.copy(
name = updatedCollection.name,
count = updatedCollection.count,
description = updatedCollection.description,
logoUrl = updatedCollection.logoUrl,
)
?: updatedCollection
}
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.nft.di
import com.tangem.data.nft.DefaultNFTRepository
import com.tangem.domain.nft.repository.NFTRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface NFTDataModule {
@Binds
@Singleton
fun bindNFTRepository(repository: DefaultNFTRepository): NFTRepository
}

View file

@ -15,6 +15,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.common.trustlines.AssetRequirementsManager
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.crypto.hdWallet.DerivationPath
@ -639,6 +641,47 @@ class DefaultWalletManagersFacade(
return (walletManager as? UtxoBlockchainManager)?.allowConsolidation == true
}
override suspend fun getNFTCollections(userWalletId: UserWalletId, network: Network): List<NFTCollection> {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return emptyList()
val address = walletManager.wallet.address
return walletManager.getCollections(address)
}
override suspend fun getNFTAssets(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
): List<NFTAsset> {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return emptyList()
val address = walletManager.wallet.address
return walletManager.getAssets(address, collectionIdentifier)
}
override suspend fun getAsset(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
assetIdentifier: NFTAsset.Identifier,
): NFTAsset? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return null
return walletManager.getAsset(collectionIdentifier, assetIdentifier)
}
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {
if (tokens.isEmpty()) return

View file

@ -10,6 +10,8 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.blockchain.nft.models.NFTCollection
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.models.AssetRequirementsCondition
@ -248,4 +250,19 @@ interface WalletManagersFacade {
* @param network availability for network
*/
suspend fun checkUtxoConsolidationAvailability(userWalletId: UserWalletId, network: Network): Boolean
suspend fun getNFTCollections(userWalletId: UserWalletId, network: Network): List<NFTCollection>
suspend fun getNFTAssets(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
): List<NFTAsset>
suspend fun getAsset(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
assetIdentifier: NFTAsset.Identifier,
): NFTAsset?
}

1
domain/nft/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,22 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.domain.nft"
}
dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
}

1
domain/nft/models/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
}

View file

@ -0,0 +1,52 @@
package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Network
sealed class NFTAsset {
abstract val id: Identifier
data class Value(
override val id: Identifier,
val collectionId: NFTCollection.Identifier,
val network: Network,
val contractType: String,
val owner: String?,
val name: String?,
val description: String?,
val salePrice: NFTSalePrice,
val rarity: Rarity?,
val media: Media?,
val traits: List<Trait>,
val source: StatusSource,
) : NFTAsset() {
data class Media(
val mimetype: String,
val url: String,
)
data class Rarity(
val rank: String,
val label: String,
)
data class Trait(
val name: String,
val value: String,
)
}
data class Error(
override val id: Identifier,
) : NFTAsset()
sealed class Identifier {
data class EVM(
val tokenId: String,
val tokenAddress: String,
) : Identifier()
data class TON(val tokenAddress: String) : Identifier()
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.nft.models
import com.tangem.domain.tokens.model.Network
data class NFTCollection(
val id: Identifier,
val network: Network,
val name: String?,
val description: String?,
val logoUrl: String?,
val count: Int,
val assets: List<NFTAsset> = emptyList(),
) {
sealed class Identifier {
data class EVM(val tokenAddress: String) : Identifier()
data class TON(val contractAddress: String?) : Identifier()
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Network
data class NFTCollections(
val network: Network,
val content: Content,
) {
sealed class Content {
data class Collections(
val collections: List<NFTCollection>?,
val source: StatusSource,
) : Content()
data class Error(
val error: Throwable,
) : Content()
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import java.math.BigDecimal
sealed class NFTSalePrice {
abstract val assetId: NFTAsset.Identifier
data class Empty(
override val assetId: NFTAsset.Identifier,
) : NFTSalePrice()
data class Error(
override val assetId: NFTAsset.Identifier,
) : NFTSalePrice()
data class Value(
override val assetId: NFTAsset.Identifier,
val value: BigDecimal,
val symbol: String,
val source: StatusSource,
) : NFTSalePrice()
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.nft.repository
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
interface NFTRepository {
fun observeCollections(userWalletId: UserWalletId, networks: List<Network>): Flow<List<NFTCollections>>
suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>)
}

View file

@ -275,6 +275,8 @@ include(":domain:manage-tokens:models")
include(":domain:onramp")
include(":domain:onramp:models")
include(":domain:promo")
include(":domain:nft")
include(":domain:nft:models")
// endregion Domain modules
// region Data modules
@ -298,6 +300,7 @@ include(":data:staking")
include(":data:wallet-connect")
include(":data:markets")
include(":data:manage-tokens")
include(":data:nft")
include(":data:onramp")
// endregion Data modules
include(":domain:promo:models")