Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:47:25 +03:00
commit c50840894f
739 changed files with 27664 additions and 4701 deletions

View file

@ -1,14 +1,9 @@
package com.tangem.data.account.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher
import com.tangem.data.account.repository.AccountsExpandedDTO
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
import com.tangem.data.account.repository.DefaultAccountsExpandedRepository
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
@ -17,14 +12,9 @@ import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.repository.AccountsExpandedRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -63,28 +53,6 @@ internal object AccountDataModule {
)
}
@Provides
@Singleton
fun provideAccountsExpandedRepository(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): AccountsExpandedRepository {
val store = DataStoreFactory.create<Map<String, Set<AccountsExpandedDTO>>>(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes(valueTypes = setTypes<AccountsExpandedDTO>()),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "account_expanded_store") },
scope = appScope,
)
return DefaultAccountsExpandedRepository(
store = store,
)
}
@Provides
@Singleton
fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl

View file

@ -0,0 +1,18 @@
package com.tangem.data.account.di
import com.tangem.data.account.repository.DefaultAccountsExpandedRepository
import com.tangem.domain.account.repository.AccountsExpandedRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AccountUtilsModule {
@Binds
fun provideAccountsExpandedRepositoryFactory(
factory: DefaultAccountsExpandedRepository.Factory,
): AccountsExpandedRepository.Factory
}

View file

@ -1,17 +1,28 @@
package com.tangem.data.account.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.data.account.converter.toAccountId
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.account.models.AccountExpandedState
import com.tangem.domain.account.repository.AccountsExpandedRepository
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
internal class DefaultAccountsExpandedRepository(
internal class DefaultAccountsExpandedRepository constructor(
private val store: DataStore<Map<String, Set<AccountsExpandedDTO>>>,
) : AccountsExpandedRepository {
@ -41,7 +52,7 @@ internal class DefaultAccountsExpandedRepository(
}
override suspend fun clearStore() {
store.updateData { emptyMap() }
store.updateData { map -> map.mapValues { emptySet() } }
}
override suspend fun update(accountState: AccountExpandedState) {
@ -59,6 +70,28 @@ internal class DefaultAccountsExpandedRepository(
map.plus(walletId.stringValue to updatedSet)
}
}
internal class Factory @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
@ApplicationContext private val context: Context,
private val appScope: AppCoroutineScope,
) : AccountsExpandedRepository.Factory {
override fun create(storeFileName: String): DefaultAccountsExpandedRepository {
val store = DataStoreFactory.create<Map<String, Set<AccountsExpandedDTO>>>(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes(valueTypes = setTypes<AccountsExpandedDTO>()),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = storeFileName) },
scope = appScope,
)
return DefaultAccountsExpandedRepository(
store = store,
)
}
}
}
@JsonClass(generateAdapter = true)

View file

@ -7,22 +7,25 @@ plugins {
}
android {
namespace = "com.tangem.data.tokensync"
namespace = "com.tangem.data.assetsdiscovery"
}
dependencies {
api(projects.domain.tokensync)
api(projects.domain.assetsdiscovery)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.data.common)
implementation(projects.data.walletManager)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.tangemSdkApi)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(deps.androidx.datastore)

View file

@ -0,0 +1,86 @@
package com.tangem.data.assetsdiscovery
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.walletmanager.extensions.makePublicKey
import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultAssetsDiscoveryFacade @Inject constructor(
private val blockchainSDKFactory: BlockchainSDKFactory,
private val userWalletsListRepository: UserWalletsListRepository,
private val dispatchers: CoroutineDispatcherProvider,
) : AssetsDiscoveryFacade {
override suspend fun getAssetsDiscoveryService(
userWalletId: UserWalletId,
network: Network,
): AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo? = withContext(dispatchers.io) {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
if (userWallet !is UserWallet.Hot) return@withContext null
val assetsDiscoveryServiceFactory = blockchainSDKFactory.getAssetsDiscoveryServiceFactorySync()
?: return@withContext null
val blockchain = network.toBlockchain()
val address = makeAddress(userWallet, blockchain, network.derivationPath.value)
?: return@withContext null
AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo(
address = address,
service = assetsDiscoveryServiceFactory.create(blockchain),
)
}
private fun makeAddress(hotWallet: UserWallet.Hot, blockchain: Blockchain, derivationPath: String?): String? {
val curve = hotWallet.curvesConfig.primaryCurve(blockchain)
val selectedWallet = hotWallet.wallets.orEmpty().firstOrNull { it.curve == curve }
?: return null
val path = derivationPath?.let { DerivationPath(rawPath = it) }
val publicKey = if (path != null) {
makePublicKey(
seedKey = selectedWallet.publicKey,
blockchain = blockchain,
derivationPath = path,
derivedWalletKeys = selectedWallet.derivedKeys,
isWallet2 = true,
) ?: return null
} else {
null
}
return try {
val addresses = if (publicKey != null) {
blockchain.makeAddresses(
walletPublicKey = publicKey.blockchainKey,
pairPublicKey = null,
curve = selectedWallet.curve,
)
} else {
blockchain.makeAddresses(
walletPublicKey = selectedWallet.publicKey,
pairPublicKey = null,
curve = selectedWallet.curve,
)
}
addresses.find { it.type == AddressType.Default }?.value
} catch (e: Throwable) {
TangemLogger.w("Failed to derive address for $blockchain", e)
null
}
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.assetsdiscovery.di
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.assetsdiscovery.DefaultAssetsDiscoveryFacade
import com.tangem.data.assetsdiscovery.repository.DefaultAssetsDiscoveryRepository
import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AssetsDiscoveryDataModule {
@Provides
@Singleton
fun provideAssetsDiscoveryFacade(
blockchainSDKFactory: BlockchainSDKFactory,
userWalletsListRepository: UserWalletsListRepository,
dispatchers: CoroutineDispatcherProvider,
): AssetsDiscoveryFacade = DefaultAssetsDiscoveryFacade(
blockchainSDKFactory = blockchainSDKFactory,
userWalletsListRepository = userWalletsListRepository,
dispatchers = dispatchers,
)
@Provides
@Singleton
fun provideAssetsDiscoveryRepository(
assetsDiscoveryFacade: AssetsDiscoveryFacade,
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
networkFactory: NetworkFactory,
appPreferencesStore: AppPreferencesStore,
assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
): AssetsDiscoveryRepository = DefaultAssetsDiscoveryRepository(
assetsDiscoveryFacade = assetsDiscoveryFacade,
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
networkFactory = networkFactory,
appPreferencesStore = appPreferencesStore,
assetsDiscoveryStoreFactory = assetsDiscoveryStoreFactory,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
)
}

View file

@ -1,12 +1,12 @@
package com.tangem.data.tokensync.repository
package com.tangem.data.assetsdiscovery.repository
import com.tangem.blockchain.assetsdiscovery.models.DiscoveredAsset
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.tokenbalance.models.TokenBalance
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStore
import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.tokensync.store.TokenSyncStore
import com.tangem.data.tokensync.store.TokenSyncStoreFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
@ -14,15 +14,15 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.model.TokenSyncProgress
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
@ -30,42 +30,39 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@Suppress("LongParameterList")
internal class DefaultTokenSyncRepository(
private val walletManagersFacade: WalletManagersFacade,
internal class DefaultAssetsDiscoveryRepository(
private val assetsDiscoveryFacade: AssetsDiscoveryFacade,
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
private val appPreferencesStore: AppPreferencesStore,
private val tokenSyncStoreFactory: TokenSyncStoreFactory,
private val assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
private val excludedBlockchains: ExcludedBlockchains,
) : TokenSyncRepository {
) : AssetsDiscoveryRepository {
private val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS)
private val progressStates = ConcurrentHashMap<String, MutableStateFlow<TokenSyncProgress>>()
private val progressStates = ConcurrentHashMap<String, MutableStateFlow<AssetsDiscoveryProgress>>()
override fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress> {
override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow<AssetsDiscoveryProgress> {
return getProgressFlow(userWalletId)
}
override fun acknowledgeCompletion(userWalletId: UserWalletId) {
val key = userWalletId.stringValue
val stateFlow = progressStates[key] ?: return
stateFlow.value = TokenSyncProgress.Idle
stateFlow.value = AssetsDiscoveryProgress.Idle
progressStates.remove(key, stateFlow)
}
override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> {
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
val storedTokens = tokenSyncStore.get()
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
val storedTokens = assetsDiscoveryStore.get()
if (storedTokens.isEmpty()) return emptyList()
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
@ -77,34 +74,34 @@ internal class DefaultTokenSyncRepository(
}
override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) {
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
tokenSyncStore.clear()
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
assetsDiscoveryStore.clear()
}
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false)
}
override suspend fun getPendingSyncWalletIds(): List<UserWalletId> {
override suspend fun getPendingDiscoveryWalletIds(): List<UserWalletId> {
val pendingMap = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY)
.getObjectMapSync<Boolean>(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY)
return pendingMap
.filter { it.value }
.map { UserWalletId(it.key) }
}
override suspend fun runSync(userWalletId: UserWalletId) {
override suspend fun runDiscovery(userWalletId: UserWalletId) {
val networks = getSupportedNetworks(userWalletId)
if (networks.isEmpty()) return
setPendingFlag(userWalletId, value = true)
val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId)
tokenSyncStore.clear()
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
assetsDiscoveryStore.clear()
val batches = networks.chunked(MAX_CONCURRENT_REQUESTS)
var completedNetworks = 0
getProgressFlow(userWalletId).value = TokenSyncProgress.InProgress(
getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.InProgress(
completedNetworks = 0,
totalNetworks = networks.size,
)
@ -114,25 +111,23 @@ internal class DefaultTokenSyncRepository(
completedNetworks = handleBatchResults(
userWalletId = userWalletId,
results = batchResults,
tokenSyncStore = tokenSyncStore,
assetsDiscoveryStore = assetsDiscoveryStore,
completedNetworks = completedNetworks,
totalNetworks = networks.size,
)
}
}
override suspend fun completeSync(userWalletId: UserWalletId) {
override suspend fun completeDiscovery(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false)
getProgressFlow(userWalletId).value = TokenSyncProgress.Completed
getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.Completed
}
private suspend fun processBatch(userWalletId: UserWalletId, batch: List<Network>): List<NetworkResult> {
return coroutineScope {
batch.map { network ->
async(dispatchers.io) {
semaphore.withPermit {
processNetwork(userWalletId, network)
}
processNetwork(userWalletId, network)
}
}.awaitAll()
}
@ -141,7 +136,7 @@ internal class DefaultTokenSyncRepository(
private suspend fun handleBatchResults(
userWalletId: UserWalletId,
results: List<NetworkResult>,
tokenSyncStore: TokenSyncStore,
assetsDiscoveryStore: AssetsDiscoveryStore,
completedNetworks: Int,
totalNetworks: Int,
): Int {
@ -150,8 +145,8 @@ internal class DefaultTokenSyncRepository(
for (result in results) {
completed++
handleNetworkResult(result, tokenSyncStore)
progressFlow.value = TokenSyncProgress.InProgress(
handleNetworkResult(result, assetsDiscoveryStore)
progressFlow.value = AssetsDiscoveryProgress.InProgress(
completedNetworks = completed,
totalNetworks = totalNetworks,
)
@ -160,79 +155,78 @@ internal class DefaultTokenSyncRepository(
return completed
}
private suspend fun handleNetworkResult(result: NetworkResult, tokenSyncStore: TokenSyncStore) {
private suspend fun handleNetworkResult(result: NetworkResult, assetsDiscoveryStore: AssetsDiscoveryStore) {
when (result) {
is NetworkResult.Success -> {
if (result.responseTokens.isNotEmpty()) {
try {
tokenSyncStore.append(result.responseTokens)
assetsDiscoveryStore.append(result.responseTokens)
} catch (e: Exception) {
TangemLogger.e("Failed to store discovered tokens for network: ${result.networkId}", e)
}
}
}
is NetworkResult.Error -> {
TangemLogger.e("Token sync failed for network: ${result.networkId}", result.cause)
TangemLogger.e("Assets discovery failed for network: ${result.networkId}", result.cause)
}
}
}
private suspend fun processNetwork(userWalletId: UserWalletId, network: Network): NetworkResult {
return try {
val tokenBalances = fetchAndFilterTokenBalances(userWalletId, network)
val discoveredAssets = discoverAndFilterAssets(userWalletId, network)
if (tokenBalances.isEmpty()) {
if (discoveredAssets.isEmpty()) {
return NetworkResult.Success(
networkId = network.backendId,
networkId = network.rawId,
responseTokens = emptyList(),
)
}
val enrichedTokens = enrichTokensWithCatalog(tokenBalances, network)
val enrichedTokens = enrichTokensWithCatalog(discoveredAssets, network)
val responseTokens = enrichedTokens
.filter { it.contractAddress != null }
.map { it.toResponseToken() }
NetworkResult.Success(
networkId = network.backendId,
networkId = network.rawId,
responseTokens = responseTokens,
)
} catch (e: Exception) {
NetworkResult.Error(networkId = network.backendId, cause = e)
NetworkResult.Error(networkId = network.rawId, cause = e)
}
}
private suspend fun fetchAndFilterTokenBalances(userWalletId: UserWalletId, network: Network): List<TokenBalance> {
return withContext(dispatchers.io) {
walletManagersFacade.getTokenBalances(userWalletId, network)
private suspend fun discoverAndFilterAssets(userWalletId: UserWalletId, network: Network): List<DiscoveredAsset> =
withContext(dispatchers.io) {
val providerInfo = assetsDiscoveryFacade.getAssetsDiscoveryService(userWalletId, network)
?: return@withContext emptyList()
providerInfo.service.discoverAssets(providerInfo.address)
.filter { it.amount > BigDecimal.ZERO }
}
}
private suspend fun enrichTokensWithCatalog(
tokenBalances: List<TokenBalance>,
assets: List<DiscoveredAsset>,
network: Network,
): List<DiscoveredToken> = withContext(dispatchers.io) {
val tokensToEnrich = tokenBalances.filter { !it.isNativeToken }
): List<EnrichedDiscoveredAsset> = withContext(dispatchers.io) {
val tokensToEnrich = assets.filterIsInstance<DiscoveredAsset.Token>()
val catalogMap = fetchCatalogInfo(
networkId = network.backendId,
contractAddresses = tokensToEnrich.mapNotNull(TokenBalance::contractAddress),
networkId = network.rawId,
contractAddresses = tokensToEnrich.map { it.contractAddress },
)
tokenBalances.mapNotNull { balance ->
if (balance.isNativeToken) return@mapNotNull null
val contractAddressLower = balance.contractAddress?.lowercase()
val coin = contractAddressLower?.let { catalogMap[it] } ?: return@mapNotNull null
tokensToEnrich.mapNotNull { balance ->
val contractAddressLower = balance.contractAddress.lowercase()
val coin = catalogMap[contractAddressLower] ?: return@mapNotNull null
val decimals = coin.networks
.find { it.contractAddress?.lowercase() == contractAddressLower }
?.decimalCount
?.toInt()
?: 0
DiscoveredToken(
EnrichedDiscoveredAsset(
contractAddress = balance.contractAddress,
symbol = coin.symbol,
name = coin.name,
@ -240,7 +234,7 @@ internal class DefaultTokenSyncRepository(
amount = balance.amount,
isNativeToken = false,
currencyId = coin.id,
networkId = network.backendId,
networkId = network.rawId,
)
}
}
@ -290,23 +284,23 @@ internal class DefaultTokenSyncRepository(
}
}
private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow<TokenSyncProgress> {
private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow<AssetsDiscoveryProgress> {
return progressStates.getOrPut(userWalletId.stringValue) {
MutableStateFlow(TokenSyncProgress.Idle)
MutableStateFlow(AssetsDiscoveryProgress.Idle)
}
}
private suspend fun setPendingFlag(userWalletId: UserWalletId, value: Boolean) {
appPreferencesStore.editData { prefs ->
prefs.setObjectMap(
key = PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY,
value = prefs.getObjectMap<Boolean>(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY)
key = PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY,
value = prefs.getObjectMap<Boolean>(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY)
.plus(userWalletId.stringValue to value),
)
}
}
private fun DiscoveredToken.toResponseToken(): UserTokensResponse.Token {
private fun EnrichedDiscoveredAsset.toResponseToken(): UserTokensResponse.Token {
return UserTokensResponse.Token(
id = currencyId,
networkId = networkId,
@ -317,7 +311,7 @@ internal class DefaultTokenSyncRepository(
)
}
private data class DiscoveredToken(
private data class EnrichedDiscoveredAsset(
val contractAddress: String?,
val symbol: String,
val name: String,

View file

@ -1,8 +1,8 @@
package com.tangem.data.tokensync.store
package com.tangem.data.assetsdiscovery.store
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
interface TokenSyncStore {
interface AssetsDiscoveryStore {
suspend fun get(): List<UserTokensResponse.Token>

View file

@ -1,4 +1,4 @@
package com.tangem.data.tokensync.store
package com.tangem.data.assetsdiscovery.store
import android.content.Context
import androidx.datastore.core.DataStore
@ -17,18 +17,18 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenSyncStoreFactory @Inject constructor(
class AssetsDiscoveryStoreFactory @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
@ApplicationContext private val context: Context,
private val appScope: AppCoroutineScope,
) {
private val stores = ConcurrentHashMap<String, TokenSyncStore>()
private val stores = ConcurrentHashMap<String, AssetsDiscoveryStore>()
fun provide(userWalletId: UserWalletId): TokenSyncStore {
fun provide(userWalletId: UserWalletId): AssetsDiscoveryStore {
val userWalletStringId = userWalletId.formatted()
return stores.computeIfAbsent(userWalletStringId) {
DefaultTokenSyncStore(
DefaultAssetsDiscoveryStore(
persistenceStore = createPersistenceStore(
fileName = "token_sync_$userWalletStringId",
types = listTypes<UserTokensResponse.Token>(),

View file

@ -1,12 +1,12 @@
package com.tangem.data.tokensync.store
package com.tangem.data.assetsdiscovery.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultTokenSyncStore(
internal class DefaultAssetsDiscoveryStore(
private val persistenceStore: DataStore<List<UserTokensResponse.Token>>,
) : TokenSyncStore {
) : AssetsDiscoveryStore {
override suspend fun get(): List<UserTokensResponse.Token> {
return persistenceStore.data.firstOrNull().orEmpty()

View file

@ -33,6 +33,7 @@ internal class DefaultBlockAidRepository(
return when (data.params) {
is TransactionParams.Evm -> scanEvmTransaction(data = data)
is TransactionParams.Solana -> scanSolanaTransaction(data = data)
is TransactionParams.Bitcoin -> scanBitcoinTransaction(data = data)
}
}
@ -59,6 +60,17 @@ internal class DefaultBlockAidRepository(
mapper.mapToDomain(response)
}
@Suppress("UnusedParameter")
private fun scanBitcoinTransaction(data: TransactionData): CheckTransactionResult {
// TODO: BlockAid API doesn't support Bitcoin transaction scanning yet
// When support is added, implement: api.scanBitcoinTransaction(mapper.mapToBitcoinRequest(data))
return CheckTransactionResult(
validation = com.domain.blockaid.models.transaction.ValidationResult.FAILED_TO_VALIDATE,
description = "Bitcoin transaction validation is not yet supported by BlockAid",
simulation = com.domain.blockaid.models.transaction.SimulationResult.FailedToSimulate,
)
}
private suspend fun scanEvmTransactionBulk(
blockchain: Blockchain,
transactionDataList: List<SDKTransactionData.Uncompiled>,

View file

@ -169,7 +169,7 @@ class CryptoCurrencyFactory(
id = cryptoCurrency.id.rawCurrencyId?.value,
)
val blockchain =
Blockchain.fromNetworkId(cryptoCurrency.network.backendId) ?: Blockchain.Unknown
Blockchain.fromNetworkId(cryptoCurrency.network.rawId) ?: Blockchain.Unknown
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(
id = id,

View file

@ -47,7 +47,7 @@ internal class DefaultCardCryptoCurrencyFactory(
// check if the blockchain of single-currency wallet is the same as network
val cardNetworkId = userWallet.scanResponse.cardTypesResolver.getBlockchain().toNetworkId()
val cardNetwork = networks.firstOrNull { it.backendId == cardNetworkId }
val cardNetwork = networks.firstOrNull { it.rawId == cardNetworkId }
if (cardNetwork == null) return emptyMap()
@ -145,7 +145,7 @@ internal class DefaultCardCryptoCurrencyFactory(
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token ->
networks.any {
it.backendId == token.networkId && it.derivationPath.value == token.derivationPath
it.rawId == token.networkId && it.derivationPath.value == token.derivationPath
}
},
userWallet = userWallet,

View file

@ -40,7 +40,7 @@ class UserTokensResponseFactory @Inject constructor() {
UserTokensResponse.Token(
id = id.rawCurrencyId?.value,
accountId = accountId?.value,
networkId = network.backendId,
networkId = network.rawId,
derivationPath = network.derivationPath.value,
name = name,
symbol = symbol,

View file

@ -136,8 +136,7 @@ class NetworkFactory @Inject constructor(
return runCatching {
Network(
id = Network.ID(value = blockchain.id, derivationPath = derivationPath),
backendId = blockchain.toNetworkId(),
id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath),
name = blockchain.fullName,
isTestnet = blockchain.isTestnet(),
derivationPath = derivationPath,

View file

@ -3,6 +3,7 @@ package com.tangem.data.common.network
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
@ -187,7 +188,7 @@ class NetworkFactoryTest {
userWallet = userWallet,
expected = MockCryptoCurrencyFactory().ethereum.network.copy(
id = Network.ID(
value = Blockchain.Ethereum.id,
value = Blockchain.Ethereum.toNetworkId(),
derivationPath = expectedDerivationPath,
),
derivationPath = expectedDerivationPath,
@ -201,12 +202,12 @@ class NetworkFactoryTest {
derivationPath: Network.DerivationPath,
): CreateTestModel.Second {
return CreateTestModel.Second(
networkId = Network.ID(value = Blockchain.Ethereum.id, derivationPath = derivationPath),
networkId = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = derivationPath),
derivationPath = derivationPath,
userWallet = userWallet,
expected = MockCryptoCurrencyFactory().ethereum.network.copy(
id = Network.ID(
value = Blockchain.Ethereum.id,
value = Blockchain.Ethereum.toNetworkId(),
derivationPath = derivationPath,
),
derivationPath = derivationPath,
@ -227,7 +228,7 @@ class NetworkFactoryTest {
derivationStyleProvider = derivationStyleProvider,
canHandleTokens = canHandleTokens,
expected = MockCryptoCurrencyFactory().ethereum.network.copy(
id = Network.ID(value = Blockchain.Ethereum.id, derivationPath = expectedDerivationPath),
id = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = expectedDerivationPath),
derivationPath = expectedDerivationPath,
canHandleTokens = canHandleTokens,
),

View file

@ -30,6 +30,7 @@ dependencies {
// region Project - Libs
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
implementation(tangemDeps.card.core)
// endregion
// region DI

View file

@ -1,10 +1,12 @@
package com.tangem.data.dynamicaddresses
import arrow.core.Either
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.DynamicAddressesManager
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.models.network.Network
@ -33,13 +35,25 @@ internal class DefaultConsolidationRepository(
when (val result = dynamicAddressesManager.createConsolidationTransaction(fee)) {
is Result.Success -> result.data
is Result.Failure -> error("Failed to create consolidation tx: ${result.error}")
is Result.Failure -> throw result.error
}
}
}
@Suppress("UnusedParameter")
private fun getNormalFee(walletManager: WalletManager): Fee {
TODO("Fee calculation for consolidation from multiple addresses is not yet implemented")
private suspend fun getNormalFee(walletManager: WalletManager): Fee {
val coinAmount = walletManager.wallet.amounts[AmountType.Coin]
?: error("Coin amount not found")
val feeResult = walletManager.getFee(
amount = coinAmount,
destination = walletManager.wallet.address,
)
return when (feeResult) {
is Result.Success -> when (val txFee = feeResult.data) {
is TransactionFee.Single -> txFee.normal
is TransactionFee.Choosable -> txFee.normal
}
is Result.Failure -> throw feeResult.error
}
}
}

View file

@ -5,11 +5,13 @@ import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
@ -31,29 +33,46 @@ internal class DefaultDynamicAddressesRepository(
.map { response ->
val token = response.findToken(network)
when {
token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED
else -> DynamicAddressesStatus.DISABLED
token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED
!walletManagersFacade.isDynamicAddressesEnabled(userWalletId, network) ->
DynamicAddressesStatus.ENABLED_REQUIRES_SETUP
else -> DynamicAddressesStatus.ENABLED
}
// TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally
}
.flowOn(dispatchers.io)
}
override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) {
withContext(dispatchers.io) {
walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
if (result is SimpleResult.Failure) {
error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) }
.onFailure { throwable ->
TangemLogger.e(
messageString = "Failed to sync tokens after dynamic addresses enable for $userWalletId",
throwable = throwable,
)
}
}
}
override suspend fun disable(userWalletId: UserWalletId, network: Network) {
withContext(dispatchers.io) {
walletManagersFacade.disableXpubMode(userWalletId, network)
val result = walletManagersFacade.disableXpubMode(userWalletId, network)
if (result is SimpleResult.Failure) {
error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) }
.onFailure { throwable ->
TangemLogger.e(
messageString = "Failed to sync tokens after dynamic addresses disable for $userWalletId",
throwable = throwable,
)
}
}
}
@ -70,6 +89,41 @@ internal class DefaultDynamicAddressesRepository(
return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network)
}
override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean {
return withContext(dispatchers.io) {
val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false
val baseDerivationPath = network.derivationPath.value ?: return@withContext false
response.accounts
.flatMap { it.tokens.orEmpty() }
.any { token ->
val tokenDerivationPath = token.derivationPath ?: return@any false
token.networkId == network.id.rawId.value &&
tokenDerivationPath != baseDerivationPath &&
DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
customPath = tokenDerivationPath,
basePath = baseDerivationPath,
)
}
}
}
override fun isDynamicAddressesEnabledForNetwork(
userWalletId: UserWalletId,
networkId: Network.ID,
): Flow<Boolean> {
return walletAccountsFetcher.get(userWalletId)
.map { response ->
response.accounts
.flatMap { it.tokens.orEmpty() }
.any { token ->
token.matchesNetwork(networkId) &&
token.dynamicAddressesEnabled == true
}
}
.flowOn(dispatchers.io)
}
private suspend fun updateTokenDynamicAddressesFlag(
userWalletId: UserWalletId,
network: Network,
@ -80,7 +134,7 @@ internal class DefaultDynamicAddressesRepository(
accounts = response.accounts.map { account ->
account.copy(
tokens = account.tokens?.map { token ->
if (token.matchesNetwork(network)) {
if (token.matchesNetwork(network.id)) {
token.copy(dynamicAddressesEnabled = enabled)
} else {
token
@ -95,12 +149,12 @@ internal class DefaultDynamicAddressesRepository(
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
return accounts
.flatMap { it.tokens.orEmpty() }
.find { it.matchesNetwork(network) }
.find { it.matchesNetwork(network.id) }
}
private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean {
return networkId == network.backendId &&
derivationPath == network.derivationPath.value &&
private fun UserTokensResponse.Token.matchesNetwork(networkId: Network.ID): Boolean {
return this.networkId == networkId.rawId.value &&
derivationPath == networkId.derivationPath.value &&
contractAddress == null
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.data.dynamicaddresses
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Provides XPUB strings for networks that need dynamic addresses restore (ENABLED_REQUIRES_SETUP).
* Uses only already-derived keys no card scan triggered.
*/
@Singleton
class DynamicAddressesInitializer @Inject constructor(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
) {
suspend fun getXpubs(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, String> {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap()
val result = mutableMapOf<Network, String>()
for (network in networks) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).firstOrNull()
if (status != DynamicAddressesStatus.ENABLED_REQUIRES_SETUP) continue
val xpub = getDerivedXpubUseCase(userWalletId, network)
if (xpub != null) {
result[network] = xpub
} else {
TangemLogger.w("Dynamic addresses enabled but XPUB not available for ${network.id}")
}
}
return result
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.feedback
import android.os.Build
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.core.navigation.email.EmailSender
import com.tangem.data.feedback.converters.BlockchainInfoConverter
import com.tangem.data.feedback.converters.WalletMetaInfoConverter
@ -10,10 +10,10 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.models.*
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.version.AppVersionProvider
import kotlinx.coroutines.flow.MutableStateFlow
@ -23,23 +23,20 @@ import java.io.File
/**
* Implementation of [FeedbackRepository]
*
* @property appLogsStore app logs store
* @property userWalletsListManager user wallets list manager
* @property userWalletsListRepository user wallets repository
* @property walletManagersStore wallet managers store
* @property emailSender email sender
* @property appVersionProvider app version provider
* @property appLogsStore app logs store
* @property userWalletsListRepository repository for getting user wallets
* @property walletManagersStore wallet managers store
* @property emailSender email sender
* @property appVersionProvider app version provider
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultFeedbackRepository(
private val appLogsStore: AppLogsStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val walletManagersStore: WalletManagersStore,
private val emailSender: EmailSender,
private val appVersionProvider: AppVersionProvider,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
) : FeedbackRepository {
private val blockchainsErrors = MutableStateFlow<Map<UserWalletId, BlockchainErrorInfo>>(emptyMap())
@ -68,16 +65,12 @@ internal class DefaultFeedbackRepository(
.map(BlockchainInfoConverter::convert)
}
override suspend fun getBlockchainInfo(
userWalletId: UserWalletId,
blockchainId: String,
derivationPath: String?,
): BlockchainInfo? {
override suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo? {
return walletManagersStore
.getSyncOrNull(
userWalletId = userWalletId,
blockchain = Blockchain.fromId(blockchainId),
derivationPath = derivationPath,
blockchain = networkId.toBlockchain(),
derivationPath = networkId.derivationPath.value,
)
?.let(BlockchainInfoConverter::convert)
}
@ -91,7 +84,7 @@ internal class DefaultFeedbackRepository(
}
override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) {
val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected")
val userWallet = userWalletsListRepository.selectedUserWallet.value ?: error("UserWallet is not selected")
blockchainsErrors.update { map ->
map.toMutableMap().apply {

View file

@ -9,7 +9,6 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
import dagger.Provides
@ -29,7 +28,6 @@ internal object FeedbackModule {
walletManagersStore: WalletManagersStore,
emailSender: EmailSender,
appVersionProvider: AppVersionProvider,
getSelectedWalletUseCase: GetSelectedWalletUseCase,
): FeedbackRepository {
return DefaultFeedbackRepository(
appLogsStore = appLogsStore,
@ -37,7 +35,6 @@ internal object FeedbackModule {
emailSender = emailSender,
appVersionProvider = appVersionProvider,
userWalletsListRepository = userWalletsListRepository,
getSelectedWalletUseCase = getSelectedWalletUseCase,
)
}

View file

@ -91,7 +91,7 @@ internal class DefaultCustomTokensRepository(
val response = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = network.backendId,
networkIds = network.rawId,
active = true,
).getOrThrow()

View file

@ -21,6 +21,7 @@ dependencies {
// region Project - Data
implementation(projects.data.common)
implementation(projects.data.dynamicAddresses)
// endregion
// region Project - Domain

View file

@ -1,15 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:CommonNetworkStatusFetcher.kt$CommonNetworkStatusFetcher${ Timber.e("Failed to fetch network status for $userWalletId [${network.rawId}]: $it") networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) }</ID>
<ID>MultilineLambdaItParameter:DefaultMultiNetworkStatusFetcher.kt$DefaultMultiNetworkStatusFetcher${ networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) raise(it) }</ID>
<ID>MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return emptyList() }</ID>
<ID>MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return@withContext }</ID>
<ID>MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( id = currencyIdConverter.convertBack(value = it.key), amount = amount.value, ) }</ID>
<ID>MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val currencyId = currencyIdConverter.convert(value = it.id) val amount = NetworkStatus.Amount.Loaded(value = it.amount) currencyId to amount }</ID>
<ID>MultilineLambdaItParameter:NetworkStatusSupplierModule.kt$NetworkStatusSupplierModule.&lt;no name provided&gt;${ "single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" + it.network.derivationPath.value }</ID>
<ID>MultilineLambdaItParameter:NetworkYieldSupplyStatusConverter.kt$NetworkYieldSupplyStatusConverter${ val id = currencyIdConverter.convert(value = it.id) val status = YieldSupplyStatus( isActive = it.isActive, isInitialized = it.isInitialized, isAllowedToSpend = it.isAllowedToSpend, effectiveProtocolBalance = it.effectiveProtocolBalance, ) id to status }</ID>
<ID>SuspendFunSwallowedCancellation:DefaultNetworksRepository.kt$DefaultNetworksRepository$runCatching</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -12,33 +12,33 @@ private typealias AmountsDomainModel = Map<CryptoCurrency.ID, NetworkStatus.Amou
/**
* Converter from [AmountsDataModel] to [AmountsDomainModel] and vice versa
*
* @param rawNetworkId the raw network ID associated with the currency
* @param blockchainId the blockchain ID associated with the network (e.g. `Blockchain.id`)
* @param derivationPath the derivation path used for the network
*
[REDACTED_AUTHOR]
*/
internal class NetworkAmountsConverter(
rawNetworkId: String,
blockchainId: String,
derivationPath: Network.DerivationPath,
) : TwoWayConverter<AmountsDataModel, AmountsDomainModel> {
private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath)
private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath)
override fun convert(value: AmountsDataModel): AmountsDomainModel {
return value.associate {
val currencyId = currencyIdConverter.convert(value = it.id)
val amount = NetworkStatus.Amount.Loaded(value = it.amount)
return value.associate { currencyAmount ->
val currencyId = currencyIdConverter.convert(value = currencyAmount.id)
val amount = NetworkStatus.Amount.Loaded(value = currencyAmount.amount)
currencyId to amount
}
}
override fun convertBack(value: AmountsDomainModel): AmountsDataModel {
return value.mapNotNull {
val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null
return value.mapNotNull { (currencyId, networkAmount) ->
val amount = networkAmount as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null
CurrencyAmount(
id = currencyIdConverter.convertBack(value = it.key),
id = currencyIdConverter.convertBack(value = currencyId),
amount = amount.value,
)
}

View file

@ -1,6 +1,6 @@
package com.tangem.data.networks.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER
@ -12,13 +12,13 @@ import com.tangem.domain.models.currency.CryptoCurrency.ID.Suffix as CurrencyIdS
/**
* Converts between [CurrencyId] and [CryptoCurrency.ID].
*
* @property rawNetworkId the raw network ID associated with the currency
* @property blockchainId the blockchain ID associated with the currency
* @property derivationPath the derivation path used for the network
*
[REDACTED_AUTHOR]
*/
internal class CurrencyIdConverter(
private val rawNetworkId: String,
internal class NetworkCurrencyIdConverter(
private val blockchainId: String,
private val derivationPath: Network.DerivationPath,
) : TwoWayConverter<CurrencyId, CryptoCurrency.ID> {
@ -31,7 +31,7 @@ internal class CurrencyIdConverter(
return if (contractAddress.isNullOrBlank()) {
getCoinId(
coinId = rawId.takeUnless { it.isNullOrBlank() }
?: error("Coin id is null for $rawNetworkId with $derivationPath"),
?: error("Coin id is null for $blockchainId with $derivationPath"),
)
} else {
getTokenId(
@ -43,14 +43,12 @@ internal class CurrencyIdConverter(
override fun convertBack(value: CryptoCurrency.ID): CurrencyId {
return if (value.isCoin) {
CurrencyId.createCoinId(
coinId = Blockchain.fromId(value.rawNetworkId).toCoinId(),
)
CurrencyId.createCoinId(coinId = value.toBlockchain().toCoinId())
} else {
CurrencyId.createTokenId(
rawTokenId = value.rawCurrencyId?.value,
contractAddress = requireNotNull(value.contractAddress) {
"Token contractAddress is null for token id: $this"
"Token contractAddress is null for token id: $value"
},
)
}
@ -82,17 +80,17 @@ internal class CurrencyIdConverter(
return when (derivationPath) {
is Network.DerivationPath.Card -> {
CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = rawNetworkId,
rawId = blockchainId,
derivationPath = derivationPath.value,
)
}
is Network.DerivationPath.Custom -> {
CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = rawNetworkId,
rawId = blockchainId,
derivationPath = derivationPath.value,
)
}
is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId)
is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(blockchainId)
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.networks.converters
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.utils.converter.Converter
@ -15,17 +16,18 @@ internal object NetworkStatusDataModelConverter : Converter<NetworkStatus, Netwo
return when (val status = value.value) {
is NetworkStatus.Verified -> {
val address = NetworkAddressConverter.convertBack(value = status.address)
val blockchainId = value.network.toBlockchain().id
val amountsConverter = NetworkAmountsConverter(
rawNetworkId = value.network.rawId,
blockchainId = blockchainId,
derivationPath = value.network.derivationPath,
)
val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter(
rawNetworkId = value.network.rawId,
blockchainId = blockchainId,
derivationPath = value.network.derivationPath,
)
NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(value = value.network.rawId),
networkId = NetworkStatusDM.ID(value = blockchainId),
derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath),
selectedAddress = address.selectedAddress,
availableAddresses = address.addresses,
@ -35,9 +37,10 @@ internal object NetworkStatusDataModelConverter : Converter<NetworkStatus, Netwo
}
is NetworkStatus.NoAccount -> {
val address = NetworkAddressConverter.convertBack(value = status.address)
val blockchainId = value.network.toBlockchain().id
NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(value = value.network.rawId),
networkId = NetworkStatusDM.ID(value = blockchainId),
derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath),
selectedAddress = address.selectedAddress,
availableAddresses = address.addresses,

View file

@ -10,20 +10,20 @@ private typealias YieldSupplyStatusDataModel = List<NetworkStatusDM.YieldSupplyS
private typealias YieldSupplyStatusDomainModel = Map<CryptoCurrency.ID, YieldSupplyStatus?>
internal class NetworkYieldSupplyStatusConverter(
rawNetworkId: String,
blockchainId: String,
derivationPath: Network.DerivationPath,
) : TwoWayConverter<YieldSupplyStatusDataModel, YieldSupplyStatusDomainModel> {
private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath)
private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath)
override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel {
return value.associate {
val id = currencyIdConverter.convert(value = it.id)
return value.associate { yieldSupplyStatus ->
val id = currencyIdConverter.convert(value = yieldSupplyStatus.id)
val status = YieldSupplyStatus(
isActive = it.isActive,
isInitialized = it.isInitialized,
isAllowedToSpend = it.isAllowedToSpend,
effectiveProtocolBalance = it.effectiveProtocolBalance,
isActive = yieldSupplyStatus.isActive,
isInitialized = yieldSupplyStatus.isInitialized,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,
effectiveProtocolBalance = yieldSupplyStatus.effectiveProtocolBalance,
)
id to status

View file

@ -1,5 +1,7 @@
package com.tangem.data.networks.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.StatusSource
@ -23,14 +25,15 @@ internal object SimpleNetworkStatusConverter : Converter<NetworkStatusDM, Simple
)
val derivationPath = NetworkDerivationPathConverter.convert(value = value.derivationPath)
val blockchainId = value.networkId.value
val amountsConverter = NetworkAmountsConverter(
rawNetworkId = value.networkId.value,
blockchainId = blockchainId,
derivationPath = derivationPath,
)
val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter(
rawNetworkId = value.networkId.value,
blockchainId = blockchainId,
derivationPath = derivationPath,
)
@ -56,7 +59,7 @@ internal object SimpleNetworkStatusConverter : Converter<NetworkStatusDM, Simple
return SimpleNetworkStatus(
id = Network.ID(
value = value.networkId.value,
value = Blockchain.fromId(blockchainId).toNetworkId(),
derivationPath = NetworkDerivationPathConverter.convert(value = value.derivationPath),
),
value = status,

View file

@ -17,21 +17,26 @@ internal object NetworkStatusSupplierModule {
@Provides
@Singleton
fun provideSingleNetworkStatusSupplier(factory: SingleNetworkStatusProducer.Factory): SingleNetworkStatusSupplier {
return object : SingleNetworkStatusSupplier(
return SingleNetworkStatusSupplier(
factory = factory,
keyCreator = {
"single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" +
it.network.derivationPath.value
keyCreator = { params ->
listOf(
"single_network_status",
params.userWalletId.stringValue,
params.network.rawId,
params.network.derivationPath.value,
)
.joinToString(separator = "_")
},
) {}
)
}
@Provides
@Singleton
fun provideMultiNetworkStatusSupplier(factory: MultiNetworkStatusProducer.Factory): MultiNetworkStatusSupplier {
return object : MultiNetworkStatusSupplier(
return MultiNetworkStatusSupplier(
factory = factory,
keyCreator = { "multi_networks_statuses_${it.userWalletId.stringValue}" },
) {}
)
}
}

View file

@ -43,6 +43,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
userWalletId: UserWalletId,
network: Network,
networkCurrencies: Set<CryptoCurrency>,
xpub: String? = null,
): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.default) {
val result = withContext(dispatchers.io) {
@ -52,6 +53,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor(
extraTokens = networkCurrencies
.filterIsInstance<CryptoCurrency.Token>()
.toSet(),
xpub = xpub,
)
}

View file

@ -2,16 +2,18 @@ package com.tangem.data.networks.multi
import arrow.core.raise.catch
import arrow.core.raise.ensure
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsCache
import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -27,11 +29,11 @@ import javax.inject.Inject
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
private val networksStatusesStore: NetworksStatusesStore,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher,
private val dynamicAddressesInitializer: DynamicAddressesInitializer,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiNetworkStatusFetcher {
@ -40,13 +42,21 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
val networksCurrencies = catch(
block = { createNetworksCurrenciesMap(params) },
catch = {
catch = { error ->
networksStatusesStore.setSourceAsOnlyCache(
userWalletId = params.userWalletId,
networks = params.networks,
)
raise(it)
raise(error)
},
)
val xpubByNetwork = catch(
block = { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) },
catch = { error ->
TangemLogger.e("Failed to build XPUBs for restore", error)
emptyMap()
},
)
@ -58,6 +68,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
userWalletId = params.userWalletId,
network = network,
networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
xpub = xpubByNetwork[network],
)
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
@ -33,13 +34,12 @@ internal class DefaultNetworksRepository(
override suspend fun fetchPendingTransactions(userWalletId: UserWalletId, network: Network) {
withContext(dispatchers.default) {
val currencies = runCatching {
val currencies = runSuspendCatching {
cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network)
}.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return@withContext
}
.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return@withContext
}
fetchPendingTransactions(userWalletId = userWalletId, network = network, currencies = currencies)
}
@ -49,34 +49,34 @@ internal class DefaultNetworksRepository(
userWalletId: UserWalletId,
network: Network,
): List<CryptoCurrencyAddress> {
return runCatching { cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) }
.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return emptyList()
}
.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, network).orEmpty(),
)
}
return runSuspendCatching {
cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network)
}.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return emptyList()
}.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, network).orEmpty(),
)
}
}
override suspend fun getNetworkAddresses(
userWalletId: UserWalletId,
network: Network.RawID,
): List<CryptoCurrencyAddress> {
return runCatching { cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network) }
.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return emptyList()
}
.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, currency.network).orEmpty(),
)
}
return runSuspendCatching {
cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network)
}.getOrElse { error ->
TangemLogger.e("Unable to create wallet currencies", error)
return emptyList()
}.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, currency.network).orEmpty(),
)
}
}
override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? {

View file

@ -2,6 +2,7 @@ package com.tangem.data.networks.store
import android.content.Context
import androidx.datastore.core.DataStore
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.networks.converters.NetworkStatusDataModelConverter
import com.tangem.data.networks.converters.SimpleNetworkStatusConverter
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -27,15 +28,15 @@ internal typealias WalletIdWithStatusDM = Map<String, Set<NetworkStatusDM>>
* Default implementation of [NetworksStatusesStore]
*
* @param context context
* @param scope app coroutine scope
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
*/
internal class DefaultNetworksStatusesStore(
context: Context,
scope: AppCoroutineScope,
private val runtimeStore: RuntimeSharedStore<WalletIdWithSimpleStatus>,
private val persistenceDataStore: DataStore<WalletIdWithStatusDM>,
private val scope: AppCoroutineScope,
) : NetworksStatusesStore {
init {
@ -112,7 +113,8 @@ internal class DefaultNetworksStatusesStore(
storedStatuses.toMutableMap().apply {
val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot {
networks.any { network ->
it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value
it.networkId.value == network.toBlockchain().id &&
it.derivationPath.value == network.derivationPath.value
}
}

View file

@ -16,10 +16,10 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class NetworkAmountsConverterTest {
private val rawNetworkId = "ETH"
private val rawNetworkId = "ethereum"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = NetworkAmountsConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath)
private val converter = NetworkAmountsConverter(blockchainId = rawNetworkId, derivationPath = derivationPath)
@Test
fun convert() {
@ -47,12 +47,12 @@ internal class NetworkAmountsConverterTest {
// Assert
val expected = mapOf(
ID.fromValue("coin⟨ETH$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue("coin⟨ethereum$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.ZERO),
ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.TEN),
)
@ -63,12 +63,12 @@ internal class NetworkAmountsConverterTest {
fun convertBack() {
// Arrange
val value = mapOf(
ID.fromValue("coin⟨ETH$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue("coin⟨ethereum$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.ZERO),
ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.TEN),
)

View file

@ -13,12 +13,12 @@ import org.junit.jupiter.params.ParameterizedTest
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CurrencyIdConverterTest {
class NetworkCurrencyIdConverterTest {
private val rawNetworkId = "ETH"
private val rawNetworkId = "ethereum"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = CurrencyIdConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath)
private val converter = NetworkCurrencyIdConverter(blockchainId = rawNetworkId, derivationPath = derivationPath)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ -48,7 +48,7 @@ class CurrencyIdConverterTest {
ConvertModel(
value = CurrencyId.createCoinId("ethereum"),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH$derivationPathHashCode⟩ethereum"),
CryptoCurrency.ID.fromValue(value = "coin⟨ethereum$derivationPathHashCode⟩ethereum"),
),
),
ConvertModel(
@ -71,7 +71,7 @@ class CurrencyIdConverterTest {
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
@ -82,7 +82,7 @@ class CurrencyIdConverterTest {
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
@ -93,7 +93,7 @@ class CurrencyIdConverterTest {
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
@ -104,7 +104,7 @@ class CurrencyIdConverterTest {
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
@ -114,7 +114,7 @@ class CurrencyIdConverterTest {
contractAddress = "",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH$derivationPathHashCode⟩usdt"),
CryptoCurrency.ID.fromValue(value = "coin⟨ethereum$derivationPathHashCode⟩usdt"),
),
),
ConvertModel(
@ -123,7 +123,7 @@ class CurrencyIdConverterTest {
contractAddress = " ",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH$derivationPathHashCode⟩usdt"),
CryptoCurrency.ID.fromValue(value = "coin⟨ethereum$derivationPathHashCode⟩usdt"),
),
),
)
@ -154,14 +154,14 @@ class CurrencyIdConverterTest {
private fun provideTestModels(): Collection<ConvertBackModel> = listOf(
ConvertBackModel(
value = CryptoCurrency.ID.fromValue("coin⟨ETH$derivationPathHashCode⟩ethereum"),
value = CryptoCurrency.ID.fromValue("coin⟨ethereum$derivationPathHashCode⟩ethereum"),
expected = Result.success(
CurrencyId.createCoinId("ethereum"),
),
),
ConvertBackModel(
value = CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CurrencyId.createTokenId(
@ -172,7 +172,7 @@ class CurrencyIdConverterTest {
),
ConvertBackModel(
value = CryptoCurrency.ID.fromValue(
value = "token⟨ETH$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
value = "token⟨ethereum$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CurrencyId.createTokenId(

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.network.entity.NetworkStatusDM.*
@ -49,7 +50,7 @@ internal class NetworkStatusDataModelConverterTest {
),
),
amounts = mapOf(
ID.fromValue(value = "coin⟨ETH→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO),
ID.fromValue(value = "coin⟨ethereum→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BTC"),
@ -74,7 +75,7 @@ internal class NetworkStatusDataModelConverterTest {
),
),
expected = Verified(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "",
type = DerivationPath.Type.NONE,
@ -119,7 +120,7 @@ internal class NetworkStatusDataModelConverterTest {
),
),
expected = NoAccount(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "",
type = DerivationPath.Type.NONE,

View file

@ -13,7 +13,7 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class NetworkYieldSupplyStatusConverterTest {
private val rawNetworkId = "ETH"
private val rawNetworkId = "ethereum"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath)
@ -38,8 +38,8 @@ internal class NetworkYieldSupplyStatusConverterTest {
// Assert
val expected = mapOf(
ID.fromValue("coin⟨ETH$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ETH$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
ID.fromValue("coin⟨ethereum$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ethereum$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
@ -49,9 +49,9 @@ internal class NetworkYieldSupplyStatusConverterTest {
fun convertBack() {
// Arrange
val value = mapOf(
ID.fromValue("coin⟨ETH$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ETH$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
ID.fromValue("token⟨ETH$derivationPathHashCode⟩usdc⚓0x1") to null,
ID.fromValue("coin⟨ethereum$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ethereum$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
ID.fromValue("token⟨ethereum$derivationPathHashCode⟩usdc⚓0x1") to null,
)
// Act

View file

@ -1,6 +1,7 @@
package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.datasource.local.network.entity.NetworkStatusDM
@ -48,7 +49,7 @@ internal class SimpleNetworkStatusConverterTest {
// region Verified
ConvertModel(
value = Verified(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -116,7 +117,7 @@ internal class SimpleNetworkStatusConverterTest {
// region NoAccount
ConvertModel(
value = NoAccount(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -168,7 +169,7 @@ internal class SimpleNetworkStatusConverterTest {
// region Error
ConvertModel(
value = Verified(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -189,7 +190,7 @@ internal class SimpleNetworkStatusConverterTest {
),
ConvertModel(
value = Verified(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -205,7 +206,7 @@ internal class SimpleNetworkStatusConverterTest {
),
ConvertModel(
value = Verified(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -230,7 +231,7 @@ internal class SimpleNetworkStatusConverterTest {
),
ConvertModel(
value = NoAccount(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -255,7 +256,7 @@ internal class SimpleNetworkStatusConverterTest {
),
ConvertModel(
value = NoAccount(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,
@ -276,7 +277,7 @@ internal class SimpleNetworkStatusConverterTest {
),
ConvertModel(
value = NoAccount(
networkId = ID(network.rawId),
networkId = ID(network.toBlockchain().id),
derivationPath = DerivationPath(
value = "card",
type = DerivationPath.Type.CARD,

View file

@ -52,7 +52,7 @@ internal class CommonNetworkStatusFetcherTest {
val userWalletId = UserWalletId("011")
val network = cryptoCurrencyFactory.ethereum.network
val extraTokens = setOf(
cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token,
cryptoCurrencyFactory.createToken(Blockchain.Ethereum),
)
val updateException = IllegalStateException()
@ -80,7 +80,7 @@ internal class CommonNetworkStatusFetcherTest {
val userWalletId = UserWalletId("011")
val network = cryptoCurrencyFactory.ethereum.network
val extraTokens = setOf(
cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token,
cryptoCurrencyFactory.createToken(Blockchain.Ethereum),
)
val updateResult = model.updateResult
val status = model.status
@ -143,15 +143,15 @@ internal class CommonNetworkStatusFetcherTest {
it.copy(
amounts = mapOf(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND",
value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND",
) to NetworkStatus.Amount.NotFound,
),
pendingTransactions = mapOf(
CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(),
CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND") to emptySet(),
),
yieldSupplyStatuses = mapOf(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND",
value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND",
) to null,
),
)

View file

@ -3,6 +3,7 @@ package com.tangem.data.networks.multi
import arrow.core.Either
import arrow.core.left
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore
@ -27,17 +28,21 @@ internal class DefaultMultiNetworkStatusFetcherTest {
private val networksStatusesStore: NetworksStatusesStore = mockk(relaxUnitFun = true)
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher = mockk()
private val dynamicAddressesInitializer: DynamicAddressesInitializer = mockk()
private val fetcher = DefaultMultiNetworkStatusFetcher(
networksStatusesStore = networksStatusesStore,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
commonNetworkStatusFetcher = commonNetworkStatusFetcher,
dynamicAddressesInitializer = dynamicAddressesInitializer,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher)
clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher, dynamicAddressesInitializer)
// No dynamic addresses restore by default
coEvery { dynamicAddressesInitializer.getXpubs(any(), any()) } returns emptyMap()
}
@Test
@ -62,6 +67,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
} returns ethereumFetcherResult
@ -70,6 +76,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns cardanoFetcherResult
@ -87,11 +94,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
}
@ -122,6 +131,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
} returns ethereumFetcherResult
@ -130,6 +140,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns cardanoFetcherResult
@ -147,11 +158,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
}
@ -182,6 +195,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
} returns ethereumFetcherResult
@ -190,6 +204,7 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns cardanoFetcherResult
@ -207,11 +222,13 @@ internal class DefaultMultiNetworkStatusFetcherTest {
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
}
@ -245,7 +262,220 @@ internal class DefaultMultiNetworkStatusFetcherTest {
networksStatusesStore.setSourceAsOnlyCache(params.userWalletId, params.networks)
}
coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any()) }
coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any(), any()) }
}
@Test
fun `fetch passes xpub to correct network and null to others`() = runTest {
// Arrange
val xpub = "xpub_test_eth"
val params = setupTwoNetworkParams()
coEvery { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) } returns mapOf(ethereum.network to xpub)
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = xpub,
)
} returns Either.Right(Unit)
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns Either.Right(Unit)
// Act
val actual = fetcher(params)
// Assert
val expected = Either.Right(Unit)
assertEither(actual, expected)
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = xpub,
)
}
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
}
}
@Test
fun `fetch continues with null xpub for all networks if getXpubs throws`() = runTest {
// Arrange
val params = setupTwoNetworkParams()
coEvery {
dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks)
} throws RuntimeException("XPUB derivation failed")
val fetchResult = Either.Right(Unit)
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
} returns fetchResult
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns fetchResult
// Act
val actual = fetcher(params)
// Assert
val expected = Either.Right(Unit)
assertEither(actual, expected)
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = null,
)
}
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
}
// getXpubs failure must not degrade network status to OnlyCache
coVerify(inverse = true) { networksStatusesStore.setSourceAsOnlyCache(userWalletId = any(), networks = any()) }
}
@Test
fun `fetch passes xpubs to all networks returned by getXpubs`() = runTest {
// Arrange
val ethXpub = "xpub_eth"
val adaXpub = "xpub_ada"
val params = setupTwoNetworkParams()
coEvery {
dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks)
} returns mapOf(ethereum.network to ethXpub, cardano.network to adaXpub)
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = ethXpub,
)
} returns Either.Right(Unit)
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = adaXpub,
)
} returns Either.Right(Unit)
// Act
val actual = fetcher(params)
// Assert
val expected = Either.Right(Unit)
assertEither(actual, expected)
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = ethXpub,
)
}
coVerify(exactly = 1) {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = adaXpub,
)
}
}
@Test
fun `fetch calls getXpubs with exactly the networks from params`() = runTest {
// Arrange
val params = setupTwoNetworkParams()
coEvery {
commonNetworkStatusFetcher.fetch(userWalletId = any(), network = any(), networkCurrencies = any(), xpub = any())
} returns Either.Right(Unit)
// Act
fetcher(params)
// Assert
coVerify(exactly = 1) { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) }
}
@Test
fun `fetch failure if network fetch fails when xpub is provided`() = runTest {
// Arrange
val xpub = "xpub_eth"
val params = setupTwoNetworkParams()
coEvery {
dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks)
} returns mapOf(ethereum.network to xpub)
val fetchFailure = Either.Left(IllegalStateException())
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = ethereum.network,
networkCurrencies = setOf(ethereum),
xpub = xpub,
)
} returns fetchFailure
coEvery {
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = cardano.network,
networkCurrencies = setOf(cardano),
xpub = null,
)
} returns Either.Right(Unit)
// Act
val actual = fetcher(params)
// Assert
val expected = Either.Left(IllegalStateException("Failed to fetch network statuses"))
assertEither(actual, expected)
}
private fun setupTwoNetworkParams(): MultiNetworkStatusFetcher.Params {
val params = MultiNetworkStatusFetcher.Params(
userWalletId = userWalletId,
networks = setOf(ethereum.network, cardano.network),
)
coEvery { cardCryptoCurrencyFactory.create(params.userWalletId, params.networks) } returns mapOf(
ethereum.network to listOf(ethereum),
cardano.network to listOf(cardano),
)
return params
}
private companion object {

View file

@ -568,7 +568,7 @@ internal class DefaultNFTRepository @Inject constructor(
private fun Network.canHandleNFTs(userWalletId: UserWalletId): Boolean {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val blockchain = Blockchain.fromNetworkId(backendId) ?: return false
val blockchain = Blockchain.fromNetworkId(rawId) ?: return false
return blockchain.canHandleNFTs() &&
userWallet.canHandleToken(blockchain, excludedBlockchains)

View file

@ -249,7 +249,7 @@ internal class DefaultOnrampRepository(
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
network = cryptoCurrency.network.rawId,
),
),
),
@ -314,7 +314,7 @@ internal class DefaultOnrampRepository(
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
network = cryptoCurrency.network.rawId,
),
),
),
@ -363,7 +363,7 @@ internal class DefaultOnrampRepository(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
toNetwork = cryptoCurrency.network.rawId,
paymentMethod = paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmount,
@ -436,7 +436,7 @@ internal class DefaultOnrampRepository(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
toNetwork = cryptoCurrency.network.rawId,
paymentMethod = quote.paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmountString,

View file

@ -176,7 +176,7 @@ internal class Bip321PaymentUriParserTest {
@Test
fun `includes tokens on matching network`() {
val btcToken = buildToken("BTC", "RUNE", "contractAddr")
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
@ -263,7 +263,13 @@ internal class Bip321PaymentUriParserTest {
@Test
fun `memo on network with memo support is not unsupported`() {
val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG)
val xrpCoin = buildCoin(
rawNetworkId = "xrp",
name = "XRP",
symbol = "XRP",
decimals = 6,
extrasType = Network.TransactionExtrasType.DESTINATION_TAG,
)
val result = parser.parse(
qrCode = "ripple:rAddress?dt=12345",
@ -299,9 +305,9 @@ internal class Bip321PaymentUriParserTest {
}
}
private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8)
private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8)
private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8)
private val bitcoinCoin = buildCoin("bitcoin", "Bitcoin", "BTC", decimals = 8)
private val litecoinCoin = buildCoin("litecoin", "Litecoin", "LTC", decimals = 8)
private val dogecoinCoin = buildCoin("dogecoin", "Dogecoin", "DOGE", decimals = 8)
private fun buildCoin(
rawNetworkId: String,
@ -349,8 +355,7 @@ internal class Bip321PaymentUriParserTest {
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None),
name = name,
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,

View file

@ -1,7 +1,6 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.models.currency.CryptoCurrency
@ -78,7 +77,8 @@ internal class DefaultQrScanningEventsRepositoryTest {
@Test
fun testBip021() {
every { network.id.rawId.value } returns Blockchain.Bitcoin.id
every { network.id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None)
every { network.rawId } returns "bitcoin"
positiveCase(
"$schema1:$address1",
QrResult(address = address1),
@ -128,7 +128,8 @@ internal class DefaultQrScanningEventsRepositoryTest {
@Test
fun testErc681Coin() {
every { network.id.rawId.value } returns Blockchain.Ethereum.id
every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None)
every { network.rawId } returns "ethereum"
positiveCase(
address2,
QrResult(address = address2),
@ -183,7 +184,8 @@ internal class DefaultQrScanningEventsRepositoryTest {
@Test
fun testErc681Token() {
every { network.id.rawId.value } returns Blockchain.Ethereum.id
every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None)
every { network.rawId } returns "ethereum"
positiveCase(
address2,
QrResult(address = address2),

View file

@ -382,8 +382,7 @@ internal class Eip681PaymentUriParserTest {
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None),
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,

View file

@ -233,8 +233,7 @@ internal class QrContentClassifierTest {
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None),
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,

View file

@ -198,13 +198,13 @@ internal class SolanaPaymentUriParserTest {
}
}
private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL")
private val solanaNetwork = buildNetwork("solana", "Solana", "SOL")
private val solanaCoin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("SOLANA"),
suffix = CryptoCurrency.ID.Suffix.RawID("SOLANA"),
body = CryptoCurrency.ID.Body.NetworkId("solana"),
suffix = CryptoCurrency.ID.Suffix.RawID("solana"),
),
network = solanaNetwork,
name = "Solana",
@ -217,7 +217,7 @@ internal class SolanaPaymentUriParserTest {
private val usdcToken = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("SOLANA"),
body = CryptoCurrency.ID.Body.NetworkId("solana"),
suffix = CryptoCurrency.ID.Suffix.RawID("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"),
),
network = solanaNetwork,
@ -231,8 +231,7 @@ internal class SolanaPaymentUriParserTest {
private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None),
name = name,
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,

View file

@ -238,13 +238,13 @@ internal class TronPaymentUriParserTest {
}
}
private val tronNetwork = buildNetwork("TRON", "Tron", "TRX")
private val tronNetwork = buildNetwork("tron", "Tron", "TRX")
private val tronCoin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("TRON"),
suffix = CryptoCurrency.ID.Suffix.RawID("TRON"),
body = CryptoCurrency.ID.Body.NetworkId("tron"),
suffix = CryptoCurrency.ID.Suffix.RawID("tron"),
),
network = tronNetwork,
name = "Tron",
@ -257,7 +257,7 @@ internal class TronPaymentUriParserTest {
private val usdtToken = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("TRON"),
body = CryptoCurrency.ID.Body.NetworkId("tron"),
suffix = CryptoCurrency.ID.Suffix.RawID("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
),
network = tronNetwork,
@ -271,8 +271,7 @@ internal class TronPaymentUriParserTest {
private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None),
name = name,
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,

View file

@ -13,6 +13,10 @@ android {
namespace = "com.tangem.data.settings"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.core.datasource)
@ -28,6 +32,11 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Test
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
// region Others dependencies
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)

View file

@ -0,0 +1,56 @@
package com.tangem.data.settings
import androidx.datastore.preferences.core.booleanPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
/**
* Development implementation of [HotWalletRestrictionManager].
*
* Reads and writes the restriction state from [AppPreferencesStore],
* allowing testers to toggle it via the Tester Menu.
* The preference [Flow] is converted to a [StateFlow] on construction,
* so [isCreationEnabledSync] can be called from non-suspending contexts.
* Defaults to `true` (restriction enabled) when no value is stored.
*/
internal class DevHotWalletRestrictionManager(
private val appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
) : HotWalletRestrictionManager {
private val isCreationEnabledState: StateFlow<Boolean> =
appPreferencesStore
.get(key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY, default = true)
.stateIn(
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
started = SharingStarted.Eagerly,
initialValue = true,
)
override fun isCreationEnabled(): StateFlow<Boolean> = isCreationEnabledState
override fun isCreationEnabledSync(): Boolean = isCreationEnabledState.value
override suspend fun toggleCreationEnabled() {
appPreferencesStore.editData { preferences ->
val isEnabled = preferences.getOrDefault(
key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY,
default = true,
)
preferences[IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY] = !isEnabled
}
}
private companion object {
val IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY =
booleanPreferencesKey(name = "isHotWalletCreationRestrictionEnabled")
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.settings
import com.tangem.domain.settings.HotWalletRestrictionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Production implementation of [HotWalletRestrictionManager].
*
* Hot wallet creation restriction is always enabled in production builds
* users must scan a physical Tangem card to add a wallet.
* [toggleCreationEnabled] is a no-op since the restriction cannot be changed at runtime.
*/
internal class ProdHotWalletRestrictionManager : HotWalletRestrictionManager {
private val state: StateFlow<Boolean> = MutableStateFlow(true)
override fun isCreationEnabled(): StateFlow<Boolean> = state
override fun isCreationEnabledSync(): Boolean = true
override suspend fun toggleCreationEnabled() = Unit
}

View file

@ -1,12 +1,16 @@
package com.tangem.data.settings.di
import android.content.Context
import com.tangem.data.settings.BuildConfig
import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultPermissionRepository
import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.settings.DevHotWalletRestrictionManager
import com.tangem.data.settings.ProdHotWalletRestrictionManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.PermissionRepository
import com.tangem.domain.settings.repositories.SettingsRepository
@ -55,4 +59,17 @@ internal object SettingsDataModule {
context = context,
)
}
@Provides
@Singleton
fun provideHotWalletRestrictionManager(
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): HotWalletRestrictionManager {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
} else {
ProdHotWalletRestrictionManager()
}
}
}

View file

@ -0,0 +1,99 @@
package com.tangem.data.settings
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DevHotWalletRestrictionManagerTest {
private val appPreferencesStore = mockk<AppPreferencesStore>(relaxed = true)
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
@BeforeEach
fun setup() {
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
@AfterEach
fun tearDown() {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
@Test
fun `GIVEN preference is true WHEN isCreationEnabled THEN emits true`() = runTest {
every {
appPreferencesStore.get(key = any<Preferences.Key<Boolean>>(), default = true)
} returns flowOf(true)
val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
val emitted = getEmittedValues(manager.isCreationEnabled())
assertThat(emitted).containsExactly(true)
}
@Test
fun `GIVEN preference is false WHEN isCreationEnabled THEN emits false`() = runTest {
every {
appPreferencesStore.get(key = any<Preferences.Key<Boolean>>(), default = true)
} returns flowOf(false)
val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
val emitted = getEmittedValues(manager.isCreationEnabled())
assertThat(emitted).containsExactly(false)
}
@Test
fun `GIVEN preference Flow emits true WHEN isCreationEnabledSync THEN returns cached true`() = runTest {
every {
appPreferencesStore.get(key = any<Preferences.Key<Boolean>>(), default = true)
} returns flowOf(true)
val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
assertThat(manager.isCreationEnabledSync()).isTrue()
}
@Test
fun `GIVEN preference Flow emits false WHEN isCreationEnabledSync THEN returns cached false`() = runTest {
every {
appPreferencesStore.get(key = any<Preferences.Key<Boolean>>(), default = true)
} returns flowOf(false)
val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
assertThat(manager.isCreationEnabledSync()).isFalse()
}
@Test
fun `WHEN toggleCreationEnabled THEN opens editData transaction`() = runTest {
every {
appPreferencesStore.get(key = any<Preferences.Key<Boolean>>(), default = true)
} returns flowOf(true)
coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true)
val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers)
manager.toggleCreationEnabled()
coVerify { appPreferencesStore.editData(any()) }
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.settings
import com.google.common.truth.Truth.assertThat
import com.tangem.test.core.getEmittedValues
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ProdHotWalletRestrictionManagerTest {
private val manager = ProdHotWalletRestrictionManager()
@Test
fun `WHEN isCreationEnabled THEN always emits true`() = runTest {
val emitted = getEmittedValues(manager.isCreationEnabled())
assertThat(emitted).containsExactly(true)
}
@Test
fun `WHEN isCreationEnabledSync THEN returns true`() = runTest {
assertThat(manager.isCreationEnabledSync()).isTrue()
}
@Test
fun `WHEN toggleCreationEnabled THEN isCreationEnabledSync still returns true`() = runTest {
manager.toggleCreationEnabled()
assertThat(manager.isCreationEnabledSync()).isTrue()
}
}

View file

@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository(
}
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) {
getVaults(network).getOrElse { error ->
TangemLogger.e("Error fetching P2PEthPool vaults: $error")
emptyList()

View file

@ -8,6 +8,7 @@ import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toCompressedPublicKey
import com.tangem.data.staking.converters.YieldConverter
@ -244,7 +245,7 @@ internal class DefaultStakeKitRepository(
}
override suspend fun constructTransaction(
networkId: String,
networkId: Network.RawID,
fee: Fee,
amount: Amount,
transactionId: String,
@ -320,8 +321,11 @@ internal class DefaultStakeKitRepository(
}
}
private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data {
return when (Blockchain.fromId(networkId)) {
private fun getTransactionDataType(
networkId: Network.RawID,
unsignedTransaction: String,
): TransactionData.Compiled.Data {
return when (val blockchain = networkId.toBlockchain()) {
Blockchain.Solana,
Blockchain.Cosmos,
-> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes())
@ -335,7 +339,7 @@ internal class DefaultStakeKitRepository(
?: error("Failed to parse Tron StakeKit transaction")
TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex)
}
else -> error("Unsupported blockchain")
else -> error("Unsupported blockchain: $blockchain")
}
}
@ -411,7 +415,7 @@ internal class DefaultStakeKitRepository(
}
private fun getTronResource(network: Network): TronResource? {
val blockchain = Blockchain.fromNetworkId(network.backendId)
val blockchain = Blockchain.fromNetworkId(network.rawId)
return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) {
TronResource.ENERGY

View file

@ -1,8 +1,6 @@
package com.tangem.data.staking
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.withContext
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakingRepository(
private val stakeKitRepository: StakeKitRepository,
private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingBalanceStoreV2: StakeKitBalancesStore,
private val stakeKitBalancesStore: StakeKitBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
@ -40,7 +37,8 @@ internal class DefaultStakingRepository(
cryptoCurrency: CryptoCurrency,
): Flow<StakingAvailability> {
return channelFlow {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) {
send(StakingAvailability.Unavailable)
return@channelFlow
}
@ -56,8 +54,6 @@ internal class DefaultStakingRepository(
return@channelFlow
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
val availabilityFlow = when (stakingIntegration) {
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
@ -65,7 +61,6 @@ internal class DefaultStakingRepository(
rawCurrencyId,
cryptoCurrency.symbol,
)
null -> flowOf(StakingAvailability.Unavailable)
}
availabilityFlow.collect { send(it) }
@ -76,20 +71,15 @@ internal class DefaultStakingRepository(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): StakingAvailability {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
return StakingAvailability.Unavailable
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?.takeIf(stakingFeatureToggles::isIntegrationEnabled)
?: return StakingAvailability.Unavailable
if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) {
return StakingAvailability.Unavailable
}
val rawCurrencyId = cryptoCurrency.id.rawCurrencyId
if (rawCurrencyId == null) {
return StakingAvailability.Unavailable
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?: return StakingAvailability.Unavailable
return when (stakingIntegration) {
@ -104,7 +94,7 @@ internal class DefaultStakingRepository(
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false
val hasDataStakingBalance by lazy {
balances.any { stakingBalance ->
@ -116,18 +106,6 @@ internal class DefaultStakingRepository(
}
}
private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean {
return when (cryptoCurrency.network.id.toBlockchain()) {
Blockchain.Ethereum -> {
when (cryptoCurrency) {
is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled
is CryptoCurrency.Token -> true
}
}
else -> true
}
}
private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
val userWallet = getUserWalletUseCase(userWalletId).getOrElse {
error("Failed to get user wallet")

View file

@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -54,7 +54,7 @@ internal object StakingBalanceSupplierModule {
fun provideSingleStakingBalanceSupplier(
factory: SingleStakingBalanceProducer.Factory,
): SingleStakingBalanceSupplier {
return object : SingleStakingBalanceSupplier(
return SingleStakingBalanceSupplier(
factory = factory,
keyCreator = { params ->
listOf(
@ -65,15 +65,15 @@ internal object StakingBalanceSupplierModule {
)
.joinToString(separator = "_")
},
) {}
)
}
@Provides
@Singleton
fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier {
return object : MultiStakingBalanceSupplier(
return MultiStakingBalanceSupplier(
factory = factory,
keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" },
) {}
)
}
}

View file

@ -65,7 +65,7 @@ internal object StakingDataModule {
return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository,
p2pEthPoolRepository = p2pEthPoolRepository,
stakingBalanceStoreV2 = stakeKitBalancesStore,
stakeKitBalancesStore = stakeKitBalancesStore,
dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles,

View file

@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
internal class DefaultStakingFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : StakingFeatureToggles {
override val isEthStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED)
override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean {
val toggle = integrationId.getFeatureToggle() ?: return true
return featureTogglesManager.isFeatureEnabled(toggle)
}
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
}
private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.StakeKit.Coin -> when (this) {
StakingIntegrationID.StakeKit.Coin.Ton,
StakingIntegrationID.StakeKit.Coin.Solana,
StakingIntegrationID.StakeKit.Coin.Cosmos,
StakingIntegrationID.StakeKit.Coin.Tron,
StakingIntegrationID.StakeKit.Coin.BSC,
StakingIntegrationID.StakeKit.Coin.Cardano,
-> null
}
is StakingIntegrationID.StakeKit.EthereumToken -> when (this) {
StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null
}
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.google.common.truth.Truth.assertThat
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultStakingFeatureTogglesTest {
private val featureTogglesManager: FeatureTogglesManager = mockk()
private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager)
@BeforeEach
fun resetMocks() {
clearMocks(featureTogglesManager)
}
@Test
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `existing StakeKit Coin integrations are always enabled`() {
StakingIntegrationID.StakeKit.Coin.entries.forEach { coin ->
assertThat(toggles.isIntegrationEnabled(coin)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
@Test
fun `existing StakeKit EthereumToken integrations are always enabled`() {
StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token ->
assertThat(toggles.isIntegrationEnabled(token)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
}

View file

@ -91,12 +91,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
val statusFrom = cryptoCurrencyStatusList
.firstOrNull { currencyStatus ->
currencyStatus.currency.getContractAddress() == pair.from.contractAddress &&
currencyStatus.currency.network.backendId == pair.from.network
currencyStatus.currency.network.rawId == pair.from.network
}
val statusTo = cryptoCurrencyStatusList
.firstOrNull { currencyStatus ->
currencyStatus.currency.getContractAddress() == pair.to.contractAddress &&
currencyStatus.currency.network.backendId == pair.to.network
currencyStatus.currency.network.rawId == pair.to.network
}
val mappedProviders = pair.providers
@ -143,14 +143,14 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
cryptoCurrencyList
.firstOrNull { currency ->
currency.getContractAddress() == pair.from.contractAddress &&
currency.network.backendId == pair.from.network
currency.network.rawId == pair.from.network
}
}
val statusToDeferred = async {
cryptoCurrencyList
.firstOrNull { currency ->
currency.getContractAddress() == pair.to.contractAddress &&
currency.network.backendId == pair.to.network
currency.network.rawId == pair.to.network
}
}
@ -198,10 +198,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
} else {
null
},
fromNetwork = fromCryptoCurrency.network.backendId,
fromNetwork = fromCryptoCurrency.network.rawId,
fromContractAddress = fromCryptoCurrency.getContractAddress(),
fromDecimals = fromCryptoCurrency.decimals,
toNetwork = toCryptoCurrency.network.backendId,
toNetwork = toCryptoCurrency.network.rawId,
toContractAddress = toCryptoCurrency.getContractAddress(),
toDecimals = toCryptoCurrency.decimals,
providerId = provider.providerId,
@ -255,8 +255,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
val response = tangemExpressApi.getExchangeData(
fromContractAddress = fromCurrency.getContractAddress(),
toContractAddress = toCryptoCurrency.getContractAddress(),
fromNetwork = fromCurrency.network.backendId,
toNetwork = toCryptoCurrency.network.backendId,
fromNetwork = fromCurrency.network.rawId,
toNetwork = toCryptoCurrency.network.rawId,
fromAddress = fromStatus.networkAddress?.defaultAddress?.value.orEmpty(),
toAddress = toAddress,
fromDecimals = fromCurrency.decimals,
@ -316,7 +316,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
),
body = ExchangeSentRequestBody(
txId = txId,
fromNetwork = currency.network.backendId,
fromNetwork = currency.network.rawId,
fromAddress = status.networkAddress?.defaultAddress?.value.orEmpty(),
payinAddress = payInAddress,
payinExtraId = txExtraId,

View file

@ -10,7 +10,7 @@ class TokenInfoConverter : Converter<CryptoCurrency, LeastTokenInfo> {
override fun convert(value: CryptoCurrency): LeastTokenInfo {
return LeastTokenInfo(
contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0",
network = value.network.backendId,
network = value.network.rawId,
)
}

View file

@ -134,7 +134,7 @@ internal class DefaultCurrenciesRepository(
}
override fun isNetworkFeeZero(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = Blockchain.fromNetworkId(network.backendId)
val blockchain = Blockchain.fromNetworkId(network.rawId)
return blockchain?.isNetworkFeeZero() == true
}
}

View file

@ -2,7 +2,11 @@ package com.tangem.data.tokens.repository
import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported
import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.FeeResourceAmountProvider
import com.tangem.blockchain.common.MinimumSendAmountProvider
import com.tangem.blockchain.common.ReserveAmountProvider
import com.tangem.blockchain.common.UtxoAmountLimitProvider
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.getTotalStakingBalance
import com.tangem.data.tokens.converters.UtxoConverter
import com.tangem.domain.models.currency.CryptoCurrency
@ -67,7 +71,7 @@ internal class DefaultCurrencyChecksRepository(
}
override fun isNetworkSupportedForGaslessTx(network: Network): Boolean {
val blockchain = Blockchain.fromId(network.rawId)
val blockchain = network.toBlockchain()
return blockchain.isGaslessTxSupported
}

View file

@ -1,49 +0,0 @@
package com.tangem.data.tokensync.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.tokensync.repository.DefaultTokenSyncRepository
import com.tangem.data.tokensync.store.TokenSyncStoreFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object TokenSyncDataModule {
@Provides
@Singleton
fun provideTokenSyncRepository(
walletManagersFacade: WalletManagersFacade,
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
networkFactory: NetworkFactory,
appPreferencesStore: AppPreferencesStore,
tokenSyncStoreFactory: TokenSyncStoreFactory,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
): TokenSyncRepository {
return DefaultTokenSyncRepository(
walletManagersFacade = walletManagersFacade,
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
networkFactory = networkFactory,
appPreferencesStore = appPreferencesStore,
tokenSyncStoreFactory = tokenSyncStoreFactory,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
)
}
}

View file

@ -38,7 +38,7 @@ internal class DefaultAllowanceRepository(
allowance >= requiredAmount -> AllowanceInfo.Enough(allowance)
allowance > BigDecimal.ZERO && allowance < requiredAmount &&
BlockchainUtils.isTetherInEthereum(
blockchainId = cryptoCurrency.network.rawId,
networkId = cryptoCurrency.network.rawId,
contractAddress = cryptoCurrency.contractAddress,
) -> AllowanceInfo.ResetNeeded(allowance, requiredAmount)
else -> AllowanceInfo.NotEnough(allowance, requiredAmount)

View file

@ -1,7 +1,7 @@
package com.tangem.data.transaction
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
@ -45,7 +45,7 @@ class DefaultGaslessTransactionRepository(
val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow()
if (supportedTokensData.isSuccess) {
val networkBlockchain = Blockchain.fromId(network.rawId)
val networkBlockchain = network.toBlockchain()
val supportedTokens = supportedTokensData.result.tokens
.filter {
it.chainId == networkBlockchain.getChainId()
@ -100,7 +100,7 @@ class DefaultGaslessTransactionRepository(
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.rawId)
val blockchain = network.toBlockchain()
val transactionRequest = gaslessTransactionRequestBuilder.build(
gaslessTransaction = gaslessTransactionData,
signature = signature,
@ -124,7 +124,7 @@ class DefaultGaslessTransactionRepository(
}
override fun getChainIdForNetwork(network: Network): Int {
val networkBlockchain = Blockchain.fromId(network.rawId)
val networkBlockchain = network.toBlockchain()
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
}

View file

@ -61,7 +61,7 @@ internal class DefaultTransactionRepository(
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found")
val extras = txExtras ?: getMemoExtras(networkId = network.rawId, memo)
val extras = txExtras ?: getMemoExtras(networkId = network.id.rawId, memo)
val patchedDestination = if (amount.type is AmountType.TokenYieldSupply) {
walletManager.getYieldModuleAddress()
@ -128,7 +128,7 @@ internal class DefaultTransactionRepository(
destination = destination,
userWalletId = userWalletId,
network = network,
txExtras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras,
txExtras = getMemoExtras(networkId = network.id.rawId, memo = memo) ?: extras,
)
}
@ -262,7 +262,7 @@ internal class DefaultTransactionRepository(
fee = fee ?: Fee.Common(amount = amount),
destination = destination,
).copy(
extras = getMemoExtras(networkId = network.rawId, memo = memo),
extras = getMemoExtras(networkId = network.id.rawId, memo = memo),
)
validator.validate(transactionData = transactionData)
@ -312,7 +312,7 @@ internal class DefaultTransactionRepository(
nonce: BigInteger?,
gasLimit: BigInteger?,
): TransactionExtras {
val blockchain = Blockchain.fromNetworkId(networkId = network.backendId)
val blockchain = Blockchain.fromNetworkId(networkId = network.rawId)
?: error("Blockchain not found")
return when {
blockchain.isEvm() -> {
@ -332,8 +332,8 @@ internal class DefaultTransactionRepository(
}
@Suppress("CyclomaticComplexMethod")
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)
private fun getMemoExtras(networkId: Network.RawID, memo: String?): TransactionExtras? {
val blockchain = networkId.toBlockchain()
if (memo == null) return null
return when (blockchain) {
Blockchain.Stellar -> {

View file

@ -2,6 +2,7 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -30,7 +31,7 @@ class MockedGaslessTransactionRepository(
}
override fun getChainIdForNetwork(network: Network): Int {
val networkBlockchain = Blockchain.fromId(network.rawId)
val networkBlockchain = network.toBlockchain()
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
}

View file

@ -171,7 +171,11 @@ class DefaultAllowanceRepositoryTest {
@Test
fun `returns NotEnough when partial allowance for non-tether token`() = runTest {
val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "usd-coin")
val token = buildToken(
rawNetworkId = "ethereum",
rawCurrencyId = "usd-coin",
contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
)
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
@ -187,7 +191,7 @@ class DefaultAllowanceRepositoryTest {
@Test
fun `returns NotEnough when partial allowance for tether on non-ethereum network`() = runTest {
val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "tether")
val token = buildToken(rawNetworkId = "polygon-pos", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
@ -200,7 +204,7 @@ class DefaultAllowanceRepositoryTest {
@Test
fun `returns ResetNeeded when partial allowance for tether on ethereum`() = runTest {
val token = buildToken(rawNetworkId = "ETH", rawCurrencyId = "tether")
val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
@ -216,7 +220,7 @@ class DefaultAllowanceRepositoryTest {
@Test
fun `returns ResetNeeded when partial allowance for tether on ethereum testnet`() = runTest {
val token = buildToken(rawNetworkId = "ETH/test", rawCurrencyId = "tether")
val token = buildToken(rawNetworkId = "ethereum/test", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
@ -248,8 +252,7 @@ class DefaultAllowanceRepositoryTest {
private fun buildNetwork(rawNetworkId: String): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(Network.RawID(rawNetworkId), derivationPath),
backendId = rawNetworkId,
id = Network.ID(value = rawNetworkId, derivationPath = derivationPath),
name = rawNetworkId.replaceFirstChar { it.uppercase() },
currencySymbol = "ETH",
derivationPath = derivationPath,
@ -263,7 +266,7 @@ class DefaultAllowanceRepositoryTest {
}
private fun buildToken(
rawNetworkId: String = "ETH",
rawNetworkId: String = "ethereum",
rawCurrencyId: String = "tether",
contractAddress: String = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
): CryptoCurrency.Token {

View file

@ -7,8 +7,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
@ -16,14 +16,8 @@ import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory"
/**
* Custom token parameters. Will be used only for F&F.
*/
private const val TOKEN_ID = "usd-coin"
private const val TOKEN_NAME = "USDC"
private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
private const val TOKEN_DECIMALS = 6
@Deprecated("Use TangemPayCurrencyFactory instead")
internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val errorConverter: TangemPayErrorConverter,
@ -47,32 +41,11 @@ internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)
errorConverter.convert(exception)
}
}
override fun create(userWallet: UserWallet): Either<UniversalError, CryptoCurrency.Token> {
return catch {
val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID),
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)

View file

@ -1,11 +1,14 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import arrow.core.getOrElse
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
import javax.inject.Singleton
/**
* Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM].
@ -15,10 +18,12 @@ import com.tangem.utils.converter.TwoWayConverter
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusValueDMConverter :
TwoWayConverter<PaymentAccountStatusValue, PaymentAccountStatusValueDM?> {
@Singleton
internal class PaymentAccountStatusValueDMConverter @Inject constructor(
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
) {
override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? {
fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? {
return when (value) {
is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated()
is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview(
@ -36,6 +41,7 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
displayName = value.displayName?.value,
)
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard(
isLocked = false,
@ -47,6 +53,7 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
displayName = value.displayName?.value,
)
is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed(
customerId = value.customerId,
@ -60,7 +67,7 @@ internal object PaymentAccountStatusValueDMConverter :
}
}
override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
return when (value) {
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed(
@ -80,6 +87,8 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } },
)
} else {
PaymentAccountStatusValue.Loaded(
@ -92,6 +101,8 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } },
)
}
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(

View file

@ -6,9 +6,11 @@ import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
@ -24,6 +26,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
@ -69,6 +72,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(
@ -112,6 +119,7 @@ internal interface TangemPayDataModule {
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
converter: PaymentAccountStatusValueDMConverter,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
@ -124,6 +132,7 @@ internal interface TangemPayDataModule {
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = scope,
),
converter = converter,
scope = scope,
)
}
@ -139,6 +148,14 @@ internal interface TangemPayDataModule {
) {}
}
@Provides
@Singleton
fun provideGetTangemPayCryptoCurrencyStatusUseCase(
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
): GetPaymentAccountCryptoCurrencyStatusUseCase {
return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier)
}
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(

View file

@ -0,0 +1,49 @@
package com.tangem.data.pay.entity
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.requireUserWalletsSync
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TangemPayCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
) {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
val userWallet = userWalletsListRepository.requireUserWalletsSync()
.firstOrNull { it.walletId == userWalletId }
?: error("User wallet with id $userWalletId not found")
val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain,
userWallet = userWallet,
extraDerivationPath = null,
)
return cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
)
}
companion object {
internal const val TOKEN_ID = "usd-coin"
internal const val TOKEN_NAME = "USDC"
internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
internal const val TOKEN_DECIMALS = 6
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
@ -8,6 +9,7 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderData
@ -34,6 +36,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val customerOrderRepository: CustomerOrderRepository,
private val deviceSecurity: DeviceSecurityInfoProvider,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -137,7 +140,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
val status = customerInfo.mapToPaymentAccountStatus()
val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId)
if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(account.userWalletId)
@ -164,7 +167,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
KycStatus.PENDING,
KycStatus.INIT,
KycStatus.REJECTED,
-> return customerInfo.mapToPaymentAccountStatus()
-> return customerInfo.mapToPaymentAccountStatus(account.userWalletId)
KycStatus.APPROVED -> Unit // proceed to order check
}
@ -242,11 +245,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus(account.userWalletId) },
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
@ -257,6 +260,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
} else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) {
convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
@ -267,10 +271,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
private fun convertToContentState(
userWalletId: UserWalletId,
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
): PaymentAccountStatusValue {
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return when (productInstance.frozenState) {
TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked(
source = StatusSource.ACTUAL,
@ -282,6 +288,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
cryptoCurrency = cryptoCurrency,
displayName = productInstance.displayName,
)
else -> PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
@ -293,6 +301,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
cryptoCurrency = cryptoCurrency,
displayName = productInstance.displayName,
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -15,6 +16,7 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.TangemPayEligibilityType
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWallet
@ -198,7 +200,14 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState)
val displayName = instance.displayName?.ifEmpty { null }
ProductInstance(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
)
}
return CustomerInfo(
customerId = response?.id,

View file

@ -0,0 +1,104 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.ReissueCardRequest
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultReissueCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayReissueCardStore: TangemPayReissueCardStore,
) : TangemPayReissueCardRepository {
override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either<VisaApiError, TangemPayReissueCardFee> =
either {
runSuspendCatching {
tangemPayReissueCardStore.getReissueFee(userWalletId)?.let { return Either.Right(it) }
}
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getFee(
authHeader = authHeader,
type = CARD_REPLACEMENT_FEE_TYPE,
)
}.bind()
val result = response.result
val fee = TangemPayReissueCardFee(
amount = result.amount.toBigDecimal(),
currencyCode = result.currency,
)
runSuspendCatching {
tangemPayReissueCardStore.storeReissueFee(userWalletId, fee)
}
fee
}
override suspend fun reissueCard(
userWalletId: UserWalletId,
cardId: String,
): Either<VisaApiError, TangemPayReissueOrderInfo> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.reissueCard(
authHeader = authHeader,
body = ReissueCardRequest(cardId = cardId),
)
}.bind()
TangemPayReissueOrderInfo(response.result.orderId, OrderStatus.fromString(response.result.status))
}
override suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayReissueCardStore.storeReissueOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getReissueOrderInfo(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayReissueOrderInfo?> = either {
val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
if (orderId == null) {
return null.right()
}
val order = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.bind()
val result = order.result ?: raise(VisaApiError.Unspecified)
TangemPayReissueOrderInfo(
orderId = result.id,
orderStatus = when (result.status) {
OrderResponse.Result.Status.NEW -> OrderStatus.NEW
OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING
OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED
OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED
},
)
}
private companion object {
const val CARD_REPLACEMENT_FEE_TYPE = "CARD_REPLACEMENT"
}
}

View file

@ -15,10 +15,12 @@ import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest
import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.api.pay.models.request.UpdateCardDisplayNameRequest
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
@ -318,6 +320,26 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun updateCardDisplayName(
userWalletId: UserWalletId,
displayName: CardDisplayName,
): Either<UniversalError, Unit> {
return catch(
block = {
requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.updateCardDisplayName(
authHeader = authHeader,
body = UpdateCardDisplayNameRequest(displayName = displayName.value),
)
}.fold(
ifLeft = { error -> error.left() },
ifRight = { Unit.right() },
)
},
catch = ::catchException,
)
}
override fun cardFrozenState(cardId: String): Flow<TangemPayCardFrozenState> {
return cardFrozenStateStore.get(cardId)
}

View file

@ -29,6 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatu
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
private val converter: PaymentAccountStatusValueDMConverter,
scope: AppCoroutineScope,
) {
@ -39,7 +40,7 @@ internal class PaymentAccountStatusesStore(
runtimeStore.store(
value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) ->
val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId))
val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM)
val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM)
AccountStatus.Payment(account = account, value = statusValue)
},
)
@ -87,7 +88,7 @@ internal class PaymentAccountStatusesStore(
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) {
val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return
val statusDM = converter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)

View file

@ -34,6 +34,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.utils)
implementation(projects.core.analytics)
api(projects.core.configToggles)
/* DI */
implementation(deps.hilt.core)

View file

@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
import com.tangem.data.walletconnect.network.bitcoin.WcBitcoinNetwork
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
import com.tangem.data.walletconnect.pair.*
@ -25,6 +26,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase
@ -34,6 +36,8 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.walletconnect.featuretoggle.DefaultWalletConnectFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -162,6 +166,22 @@ internal object WalletConnectDataModule {
networksConverter = wcNetworksConverter,
)
@Provides
@Singleton
fun wcBitcoinNetwork(
@SdkMoshi moshi: Moshi,
wcNetworksConverter: WcNetworksConverter,
sessionsManager: WcSessionsManager,
factories: WcBitcoinNetwork.Factories,
walletManagersFacade: WalletManagersFacade,
): WcBitcoinNetwork = WcBitcoinNetwork(
moshi = moshi,
sessionsManager = sessionsManager,
factories = factories,
networksConverter = wcNetworksConverter,
walletManagersFacade = walletManagersFacade,
)
@Provides
@Singleton
fun caipNamespaceDelegate(
@ -198,11 +218,19 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox(
handlers = setOf(
ethNetwork,
solanaNetwork,
),
fun diHelperBox(
ethNetwork: WcEthNetwork,
solanaNetwork: WcSolanaNetwork,
bitcoinNetwork: WcBitcoinNetwork,
featureToggles: WalletConnectFeatureToggles,
) = DiHelperBox(
handlers = buildSet {
add(ethNetwork)
add(solanaNetwork)
if (featureToggles.isBitcoinEnabled) {
add(bitcoinNetwork)
}
},
)
@Provides
@ -210,10 +238,15 @@ internal object WalletConnectDataModule {
fun namespaceConverters(
ethNamespaceConverter: WcEthNetwork.NamespaceConverter,
solanaNamespaceConverter: WcSolanaNetwork.NamespaceConverter,
): Set<@JvmSuppressWildcards WcNamespaceConverter> = setOf(
ethNamespaceConverter,
solanaNamespaceConverter,
)
bitcoinNamespaceConverter: WcBitcoinNetwork.NamespaceConverter,
featureToggles: WalletConnectFeatureToggles,
): Set<@JvmSuppressWildcards WcNamespaceConverter> = buildSet {
add(ethNamespaceConverter)
add(solanaNamespaceConverter)
if (featureToggles.isBitcoinEnabled) {
add(bitcoinNamespaceConverter)
}
}
@Provides
@Singleton
@ -239,6 +272,14 @@ internal object WalletConnectDataModule {
return WcSolanaNetwork.NamespaceConverter(excludedBlockchains)
}
@Provides
@Singleton
fun wcBitcoinNetworkNamespaceConverter(
excludedBlockchains: ExcludedBlockchains,
): WcBitcoinNetwork.NamespaceConverter {
return WcBitcoinNetwork.NamespaceConverter(excludedBlockchains)
}
@Provides
@Singleton
fun providesWcDisconnectUseCase(
@ -248,6 +289,14 @@ internal object WalletConnectDataModule {
return WcDisconnectUseCase(sessionsManager, analytics)
}
@Provides
@Singleton
fun providesWalletConnectFeatureToggles(
featureTogglesManager: FeatureTogglesManager,
): WalletConnectFeatureToggles {
return DefaultWalletConnectFeatureToggles(featureTogglesManager)
}
internal class DiHelperBox(
val handlers: Set<WcRequestToUseCaseConverter>,
)

View file

@ -0,0 +1,13 @@
package com.tangem.data.walletconnect.featuretoggle
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles
internal class DefaultWalletConnectFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : WalletConnectFeatureToggles {
override val isBitcoinEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.WALLET_CONNECT_BITCOIN_ENABLED)
}

View file

@ -9,7 +9,7 @@ import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.utils.logging.TangemLogger

View file

@ -0,0 +1,112 @@
package com.tangem.data.walletconnect.network.bitcoin
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* JSON request model for sendTransfer method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSendTransferRequest(
@Json(name = "account")
val account: String,
@Json(name = "recipientAddress")
val recipientAddress: String,
@Json(name = "amount")
val amount: String,
@Json(name = "memo")
val memo: String? = null,
@Json(name = "changeAddress")
val changeAddress: String? = null,
)
/**
* JSON request model for getAccountAddresses method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinGetAccountAddressesRequest(
@Json(name = "account")
val account: String? = null,
@Json(name = "intentions")
val intentions: List<String>? = null,
)
/**
* JSON request model for signPsbt method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignPsbtRequest(
@Json(name = "psbt")
val psbt: String,
@Json(name = "signInputs")
val signInputs: List<WcBitcoinSignInput>,
@Json(name = "broadcast")
val isBroadcast: Boolean? = false,
)
/**
* JSON model for sign input specification.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignInput(
@Json(name = "address")
val address: String,
@Json(name = "index")
val index: Int,
@Json(name = "sighashTypes")
val sighashTypes: List<Int>? = null,
)
/**
* JSON request model for signMessage method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignMessageRequest(
@Json(name = "account")
val account: String,
@Json(name = "message")
val message: String,
@Json(name = "address")
val address: String? = null,
@Json(name = "protocol")
val protocol: String? = "ecdsa",
)
/**
* JSON response model for getAccountAddresses method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinGetAccountAddressesResponse(
@Json(name = "addresses")
val addresses: List<WcBitcoinAddressInfo>,
)
/**
* Address information in getAccountAddresses response.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinAddressInfo(
@Json(name = "address")
val address: String,
@Json(name = "publicKey")
val publicKey: String? = null,
@Json(name = "path")
val path: String? = null,
@Json(name = "intention")
val intention: String? = null,
)

View file

@ -0,0 +1,118 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.AccountAddress
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.datasource.di.SdkMoshi
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase
import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Use case for Bitcoin getAccountAddresses WalletConnect method.
*
* Returns wallet addresses filtered by intention (payment/ordinal).
* This is a non-signing operation.
*/
@JsonClass(generateAdapter = true)
internal data class AddressInfo(
@Json(name = "address") val address: String,
@Json(name = "publicKey") val publicKey: String? = null,
@Json(name = "path") val path: String? = null,
@Json(name = "intention") val intention: String? = null,
)
internal class WcBitcoinGetAccountAddressesUseCase @AssistedInject constructor(
@Assisted val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.GetAccountAddresses,
private val walletManagersFacade: WalletManagersFacade,
private val respondService: WcRespondService,
@SdkMoshi private val moshi: Moshi,
) : WcGetAddressesUseCase {
override val wallet get() = context.session.wallet
override val session: WcSession
get() = context.session
override val rawSdkRequest: WcSdkSessionRequest
get() = context.rawSdkRequest
override val network: Network
get() = context.network
override val derivationState: WcNetworkDerivationState = when {
context.networkDerivationsCount > 1 -> WcNetworkDerivationState.Multiple(walletAddress = context.accountAddress)
else -> WcNetworkDerivationState.Single
}
override suspend fun invoke(): Either<HandleMethodError, WcGetAddressesUseCase.GetAddressesResult> {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: return HandleMethodError.UnknownError("Failed to create wallet manager").left()
return when (val result = walletManager.getAddresses(filterOptions = method.intentions)) {
is SdkResult.Success -> {
val accountAddresses = result.data.map { addressInfo ->
AccountAddress(
address = addressInfo.address,
publicKey = addressInfo.publicKey,
path = addressInfo.derivationPath,
intention = addressInfo.metadata?.get("intention") as? String,
)
}
val response = buildJsonResponse(accountAddresses)
respondService.respond(rawSdkRequest, response)
WcGetAddressesUseCase.GetAddressesResult(
addresses = accountAddresses.map { addr ->
WcGetAddressesUseCase.AddressInfo(
address = addr.address,
publicKey = addr.publicKey,
path = addr.path,
intention = addr.intention,
)
},
).right()
}
is SdkResult.Failure -> {
HandleMethodError.UnknownError(result.error.customMessage).left()
}
}
}
override fun reject() {
respondService.rejectRequestNonBlock(rawSdkRequest)
}
private fun buildJsonResponse(accountAddresses: List<AccountAddress>): String {
val addresses = accountAddresses.map { addr ->
AddressInfo(
address = addr.address,
publicKey = addr.publicKey,
path = addr.path,
intention = addr.intention,
)
}
return moshi.adapter<List<AddressInfo>>(
com.squareup.moshi.Types.newParameterizedType(List::class.java, AddressInfo::class.java),
).toJson(addresses)
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcBitcoinMethod.GetAccountAddresses,
): WcBitcoinGetAccountAddressesUseCase
}
}

View file

@ -0,0 +1,214 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcBitcoinMethodName
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.method.WcMethodUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import jakarta.inject.Inject
/**
* WalletConnect request handler for Bitcoin blockchain.
*
* Handles Bitcoin-specific RPC methods: sendTransfer, getAccountAddresses, signPsbt, signMessage.
*
* @see <a href="https://docs.reown.com/advanced/multichain/rpc-reference/bitcoin-rpc">Bitcoin RPC Reference</a>
*/
internal class WcBitcoinNetwork(
private val moshi: Moshi,
private val sessionsManager: WcSessionsManager,
private val factories: Factories,
private val networksConverter: WcNetworksConverter,
private val walletManagersFacade: WalletManagersFacade,
) : WcRequestToUseCaseConverter {
override fun toWcMethodName(request: WcSdkSessionRequest): WcBitcoinMethodName? {
val methodKey = request.request.method
return WcBitcoinMethodName.entries.find { it.raw == methodKey }
}
@Suppress("CyclomaticComplexMethod")
override suspend fun toUseCase(request: WcSdkSessionRequest): Either<HandleMethodError, WcMethodUseCase> {
fun error(message: String) = HandleMethodError.UnknownError(message).left()
val name = toWcMethodName(request) ?: return error("Unknown method name")
val method: WcBitcoinMethod = name.toMethod(request)
.getOrElse { return error(it.message.orEmpty()) }
?: return error("Failed to parse $name")
val session = sessionsManager.findSessionByTopic(request.topic)
?: return HandleMethodError.UnknownSession.left()
val wallet = session.wallet
val chainId = request.chainId.orEmpty()
val account = session.account
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(
rawChainId = chainId,
account = account,
)
suspend fun anyAddress() = anyExistNetwork()
?.let { network -> walletManagersFacade.getDefaultAddress(wallet.walletId, network).orEmpty() }
.orEmpty()
val accountAddress = when (method) {
is WcBitcoinMethod.SendTransfer -> method.account
is WcBitcoinMethod.GetAccountAddresses -> method.account
is WcBitcoinMethod.SignPsbt -> method.signInputs.firstOrNull()?.address ?: anyAddress()
is WcBitcoinMethod.SignMessage -> method.address ?: method.account
}
val walletNetwork = networksConverter
.findWalletNetworkForRequest(request, session, accountAddress)
?: anyExistNetwork()
?: return error("Failed to find walletNetwork for accountAddress $accountAddress")
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = walletNetwork,
accountAddress = accountAddress,
networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(
rawChainId = chainId,
account = account,
).size,
)
val useCase = when (method) {
is WcBitcoinMethod.SendTransfer -> factories.sendTransfer.create(context, method)
is WcBitcoinMethod.GetAccountAddresses -> factories.getAccountAddresses.create(context, method)
is WcBitcoinMethod.SignPsbt -> factories.signPsbt.create(context, method)
is WcBitcoinMethod.SignMessage -> factories.signMessage.create(context, method)
}
return useCase.right()
}
private fun WcBitcoinMethodName.toMethod(request: WcSdkSessionRequest): Either<Throwable, WcBitcoinMethod?> {
val rawParams = request.request.params
return when (this) {
WcBitcoinMethodName.SendTransfer -> moshi.fromJson<WcBitcoinSendTransferRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SendTransfer(
account = req.account,
recipientAddress = req.recipientAddress,
amount = req.amount,
memo = req.memo,
changeAddress = req.changeAddress,
)
}
WcBitcoinMethodName.GetAccountAddresses -> moshi.fromJson<WcBitcoinGetAccountAddressesRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.GetAccountAddresses(
account = req.account.orEmpty(),
intentions = req.intentions,
)
}
WcBitcoinMethodName.SignPsbt -> moshi.fromJson<WcBitcoinSignPsbtRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SignPsbt(
psbt = req.psbt,
signInputs = req.signInputs.map { input ->
WcBitcoinMethod.SignInput(
address = input.address,
index = input.index,
sighashTypes = input.sighashTypes,
)
},
shouldBroadcast = req.isBroadcast == true,
)
}
WcBitcoinMethodName.SignMessage -> moshi.fromJson<WcBitcoinSignMessageRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SignMessage(
account = req.account,
message = req.message,
address = req.address,
protocol = req.protocol ?: "ecdsa",
)
}
}.right()
}
/**
* Bitcoin namespace converter for CAIP-2 chain IDs.
*
* Bitcoin uses BIP-122 namespace with genesis block hash as reference.
* Example: bip122:000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
*/
internal class NamespaceConverter @Inject constructor(
override val excludedBlockchains: ExcludedBlockchains,
) : WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey(NAMESPACE)
override fun toBlockchain(chainId: CAIP2): Blockchain? {
if (chainId.namespace != namespaceKey.key) return null
return when {
isMainnetReference(chainId.reference) -> Blockchain.Bitcoin
isTestnetReference(chainId.reference) -> Blockchain.BitcoinTestnet
else -> null
}
}
private fun isMainnetReference(reference: String): Boolean {
return MAINNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } ||
reference.equals("mainnet", ignoreCase = true)
}
private fun isTestnetReference(reference: String): Boolean {
return TESTNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } ||
reference.equals("testnet", ignoreCase = true)
}
companion object {
private const val NAMESPACE = "bip122"
// Bitcoin mainnet genesis hash prefixes (supports any truncated version)
private val MAINNET_GENESIS_PREFIX = listOf(
"000000000019d6689c085ae165831e93", // Mainnet genesis hash prefix (min 32 chars for uniqueness)
)
// Bitcoin testnet genesis hash prefixes (supports any truncated version)
private val TESTNET_GENESIS_PREFIX = listOf(
"000000000933ea01ad0ee984209779ba", // Standard testnet genesis hash prefix (9 leading zeros)
"0000000000933ea01ad0ee984209779ba", // Alternative testnet prefix (10 leading zeros)
)
}
}
/**
* Factory classes for creating Bitcoin WalletConnect use cases.
*/
internal class Factories @Inject constructor(
val sendTransfer: WcBitcoinSendTransferUseCase.Factory,
val getAccountAddresses: WcBitcoinGetAccountAddressesUseCase.Factory,
val signPsbt: WcBitcoinSignPsbtUseCase.Factory,
val signMessage: WcBitcoinSignMessageUseCase.Factory,
)
companion object {
private const val NAMESPACE = "bip122"
}
}

View file

@ -0,0 +1,146 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.tangem.blockchain.blockchains.bitcoin.BitcoinTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
import com.tangem.domain.walletconnect.usecase.method.WcMutableFee
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
/**
* Use case for Bitcoin sendTransfer WalletConnect method.
*
* Sends a Bitcoin transfer transaction with optional memo (OP_RETURN) and custom change address.
*/
@Suppress("LongParameterList")
internal class WcBitcoinSendTransferUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SendTransfer,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<WcBitcoinTxAction, TransactionData>(),
WcTransactionUseCase,
WcMutableFee {
override val wallet get() = context.session.wallet
private val transferAmount: Amount by lazy {
createAmountFromSatoshis(method.amount)
}
override val securityStatus: LceFlow<Throwable, BlockAidTransactionCheck.Result> =
blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
).map { lce ->
lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) }
}
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
when (val result = walletManager.send(state.signModel, signer)) {
is SdkResult.Success -> {
val response = buildJsonResponse(result.data.hash)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left()))
}
}
}
override suspend fun FlowCollector<TransactionData>.onMiddleAction(
signModel: TransactionData,
action: WcBitcoinTxAction,
) {
val uncompiled = signModel as? TransactionData.Uncompiled ?: return
val newState = when (action) {
is WcBitcoinTxAction.UpdateFee -> uncompiled.copy(fee = action.fee)
}
emit(newState)
}
override suspend fun dAppFee(): Fee? = null
override fun updateFee(fee: Fee) {
middleAction(WcBitcoinTxAction.UpdateFee(fee))
}
override fun invoke(): Flow<WcSignState<TransactionData>> = flow {
val fee = dAppFee()
val transactionData = createTransactionData(fee)
emitAll(delegate.invoke(transactionData))
}
private fun createTransactionData(fee: Fee?): TransactionData.Uncompiled {
return TransactionData.Uncompiled(
amount = transferAmount,
fee = fee,
sourceAddress = context.accountAddress,
destinationAddress = method.recipientAddress,
extras = BitcoinTransactionExtras(
memo = method.memo,
changeAddress = method.changeAddress,
),
)
}
private fun createAmountFromSatoshis(satoshis: String): Amount {
val btcValue = BigDecimal(satoshis).divide(SATOSHI_IN_BTC)
return Amount(
currencySymbol = network.currencySymbol,
value = btcValue,
decimals = BITCOIN_DECIMALS,
)
}
private fun buildJsonResponse(txid: String): String = "{\"txid\":\"$txid\"}"
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SendTransfer): WcBitcoinSendTransferUseCase
}
private companion object {
val SATOSHI_IN_BTC = BigDecimal("100000000")
const val BITCOIN_DECIMALS = 8
}
}

View file

@ -0,0 +1,117 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.datasource.di.SdkMoshi
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.SimulationResult
import com.domain.blockaid.models.transaction.ValidationResult
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* Use case for Bitcoin signMessage WalletConnect method.
*
* Signs an arbitrary message using Bitcoin message signing format (BIP-137 ECDSA).
*/
@JsonClass(generateAdapter = true)
internal data class SignMessageResponse(
@Json(name = "address") val address: String,
@Json(name = "signature") val signature: String,
@Json(name = "messageHash") val messageHash: String? = null,
)
@Suppress("LongParameterList")
internal class WcBitcoinSignMessageUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SignMessage,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
@SdkMoshi private val moshi: Moshi,
) : BaseWcSignUseCase<Nothing, WcMessageSignUseCase.SignModel>(),
WcMessageSignUseCase {
override val wallet get() = context.session.wallet
// BlockAid doesn't support Bitcoin message signing
override val securityStatus: LceFlow<Throwable, CheckTransactionResult> = flowOf(
Lce.Content(
CheckTransactionResult(
validation = ValidationResult.FAILED_TO_VALIDATE,
simulation = SimulationResult.FailedToSimulate,
),
),
)
override suspend fun SignCollector<WcMessageSignUseCase.SignModel>.onSign(
state: WcSignState<WcMessageSignUseCase.SignModel>,
) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
// Use the address from method, or fallback to account if not specified
val addressToSign = method.address ?: method.account
// Use MessageSigner to sign the message
when (val result = walletManager.signMessage(
message = method.message,
address = addressToSign,
protocol = method.protocol,
signer = signer,
)) {
is SdkResult.Success -> {
val response = buildJsonResponse(result.data)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left()))
}
}
}
override fun invoke(): Flow<WcSignState<WcMessageSignUseCase.SignModel>> {
return delegate.invoke(initModel = WcMessageSignUseCase.SignModel(method.message))
}
private fun buildJsonResponse(data: com.tangem.blockchain.common.messagesigning.MessageSignatureResult): String {
val response = SignMessageResponse(
address = data.address,
signature = data.signature,
messageHash = data.messageHash,
)
return moshi.adapter(SignMessageResponse::class.java).toJson(response)
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignMessage): WcBitcoinSignMessageUseCase
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.datasource.di.SdkMoshi
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Use case for Bitcoin signPsbt WalletConnect method.
*
* Signs a Partially Signed Bitcoin Transaction (BIP-174 PSBT) with optional broadcast.
*/
@JsonClass(generateAdapter = true)
internal data class SignPsbtResponse(
@Json(name = "psbt") val psbt: String,
@Json(name = "txid") val txid: String? = null,
)
@Suppress("LongParameterList")
internal class WcBitcoinSignPsbtUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SignPsbt,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
blockAidDelegate: BlockAidVerificationDelegate,
@SdkMoshi private val moshi: Moshi,
) : BaseWcSignUseCase<Nothing, TransactionData>(),
WcTransactionUseCase {
override val wallet get() = context.session.wallet
override val securityStatus: LceFlow<Throwable, BlockAidTransactionCheck.Result> =
blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
).map { lce ->
lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) }
}
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
// Update wallet manager to refresh UTXO data before processing Bitcoin transaction
walletManagersFacade.update(
userWalletId = wallet.walletId,
network = network,
extraTokens = emptySet(),
)
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
val signInputs = method.signInputs.map { input ->
SignInput(
address = input.address,
index = input.index,
sighashTypes = input.sighashTypes,
)
}
val signedPsbtResult = walletManager.signPsbt(
psbtBase64 = method.psbt,
signInputs = signInputs,
signer = signer,
)
when (signedPsbtResult) {
is SdkResult.Success -> {
val signedPsbt = signedPsbtResult.data
val txid = if (method.shouldBroadcast) {
when (val broadcastResult = walletManager.broadcastPsbt(signedPsbt)) {
is SdkResult.Success -> broadcastResult.data
is SdkResult.Failure -> {
val error = HandleMethodError.UnknownError(broadcastResult.error.customMessage).left()
emit(state.toResult(error))
return
}
}
} else {
null
}
val response = buildJsonResponse(signedPsbt, txid)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(signedPsbtResult.error.customMessage).left()))
}
}
}
override fun invoke(): Flow<WcSignState<TransactionData>> {
val transactionData = TransactionData.Compiled(
value = TransactionData.Compiled.Data.RawString(method.psbt),
)
return delegate.invoke(transactionData)
}
private fun buildJsonResponse(signedPsbt: String, txid: String?): String {
val response = SignPsbtResponse(
psbt = signedPsbt,
txid = txid,
)
return moshi.adapter(SignPsbtResponse::class.java).toJson(response)
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignPsbt): WcBitcoinSignPsbtUseCase
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.data.walletconnect.network.bitcoin
import com.tangem.blockchain.common.transaction.Fee
sealed interface WcBitcoinTxAction {
data class UpdateFee(val fee: Fee) : WcBitcoinTxAction
}

View file

@ -2,7 +2,7 @@ package com.tangem.data.walletconnect.network.solana
import com.tangem.blockchain.extensions.decodeBase58
import com.tangem.blockchain.extensions.encodeBase64NoWrap
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.utils.converter.Converter
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject

View file

@ -8,7 +8,7 @@ import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.reown.walletkit.client.Wallet
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.walletconnect.WcAnalyticEvents

View file

@ -5,7 +5,7 @@ import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.datasource.local.walletconnect.WalletConnectStore

View file

@ -1,8 +1,9 @@
package com.tangem.data.walletconnect.request
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.BuildConfig
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
import com.tangem.data.walletconnect.utils.getDappOriginUrl
@ -43,6 +44,7 @@ internal class DefaultWcRequestService(
respondService.rejectRequestNonBlock(sr)
if (name.raw.startsWith("wallet_")) return
}
_wcRequest.trySend(name to sr)
}
@ -62,6 +64,11 @@ internal class DefaultWcRequestService(
}
private fun saveRequest(request: WcSdkSessionRequest) {
// Skip caching in debug builds since filtering is disabled
if (BuildConfig.DEBUG) {
return
}
val hash = respondService.sessionRequestHash(request)
val now = DateTime.now().millis
respondService.cachedRequest.update { it + (now to hash) }

View file

@ -4,7 +4,7 @@ import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory

View file

@ -7,7 +7,7 @@ import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.domain.walletconnect.model.WcRequestError
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.utils.logging.TangemLogger

View file

@ -6,7 +6,7 @@ import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter

Some files were not shown because too many files have changed in this diff Show more