Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-28 14:11:23 +03:00
commit 0470e1f010
1144 changed files with 48463 additions and 11166 deletions

View file

@ -12,4 +12,13 @@ interface AccountsExpandedRepository {
suspend fun syncStore(walletId: UserWalletId, existAccounts: Set<AccountId>)
suspend fun clearStore()
suspend fun update(accountState: AccountExpandedState)
interface Factory {
fun create(storeFileName: String): AccountsExpandedRepository
}
companion object {
const val MAIN_STORE_FILE_NAME = "account_expanded_store"
const val CHOOSE_TOKEN_FILE_NAME = "choose_token_account_expanded_store"
}
}

View file

@ -80,7 +80,7 @@ class ArchiveCryptoPortfolioUseCase(
val hasNotReferralToken = statuses.none { status ->
val currency = status.currency
currency.network.backendId == referralToken.networkId &&
currency.network.rawId == referralToken.networkId &&
(currency as? CryptoCurrency.Token)?.contractAddress == referralToken.contractAddress &&
status.value.networkAddress?.availableAddresses?.any { it.value == address } == true
}

View file

@ -168,7 +168,7 @@ class ManageCryptoCurrenciesUseCase(
val foundToken = accountStatus.tokenList.flattenCurrencies()
.mapNotNull { it.currency as? CryptoCurrency.Token }
.firstOrNull { token ->
token.network.backendId == networkId &&
token.network.rawId == networkId &&
!token.isCustom &&
token.contractAddress.equals(contractAddress, true)
}
@ -361,7 +361,7 @@ class ManageCryptoCurrenciesUseCase(
launch {
val assetIds = currencies.mapTo(hashSetOf()) { currency ->
ExpressAsset.ID(
networkId = currency.network.backendId,
networkId = currency.network.rawId,
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
)
}
@ -388,19 +388,19 @@ class ManageCryptoCurrenciesUseCase(
) {
constructor(network: Network) : this(
networkId = network.backendId,
networkId = network.rawId,
derivationPath = network.derivationPath,
contractAddress = null,
)
constructor(currency: CryptoCurrency) : this(
networkId = currency.network.backendId,
networkId = currency.network.rawId,
derivationPath = currency.network.derivationPath,
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
)
constructor(status: CryptoCurrencyStatus) : this(
networkId = status.currency.network.backendId,
networkId = status.currency.network.rawId,
derivationPath = status.currency.network.derivationPath,
contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress,
)

View file

@ -1,6 +1,6 @@
package com.tangem.domain.account.status.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrency
@ -180,7 +180,7 @@ internal object AccountCryptoCurrencyStatusFinder {
contractAddress: String?,
): AccountCryptoCurrency? {
return accountList.getExpectedAccounts(
rawNetworkId = networkId.rawId.value,
rawNetworkId = networkId.rawId,
derivationPath = derivationPath,
)
.asSequence()
@ -220,7 +220,7 @@ internal object AccountCryptoCurrencyStatusFinder {
internal fun AccountStatusList.getExpectedAccountStatuses(networkId: Network.ID): List<AccountStatus> {
val possibleAccountIndex = getAccountIndexOrNull(
rawNetworkId = networkId.rawId.value,
rawNetworkId = networkId.rawId,
derivationPath = networkId.derivationPath,
)
@ -239,7 +239,7 @@ internal object AccountCryptoCurrencyStatusFinder {
}
internal fun AccountStatusList.getExpectedAccountStatuses(networks: List<Network>): List<AccountStatus> {
val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.rawId, it.derivationPath) }
val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.id.rawId, it.derivationPath) }
if (possibleAccountIndexes.isEmpty()) return accountStatuses
@ -256,16 +256,14 @@ internal object AccountCryptoCurrencyStatusFinder {
// region AccountList helpers
internal fun AccountList.getExpectedAccounts(network: Network?): List<Account> {
return getExpectedAccounts(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath)
return getExpectedAccounts(rawNetworkId = network?.id?.rawId, derivationPath = network?.derivationPath)
}
private fun AccountList.getExpectedAccounts(
rawNetworkId: String?,
rawNetworkId: Network.RawID?,
derivationPath: Network.DerivationPath?,
): List<Account> {
val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath)
return when (possibleAccountIndex) {
return when (val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath)) {
null -> accounts
DerivationIndex.Main.value -> listOf(mainAccount)
// currency only in the account with specific derivation index or in the main account
@ -283,10 +281,10 @@ internal object AccountCryptoCurrencyStatusFinder {
// region Common helpers
private fun getAccountIndexOrNull(rawNetworkId: String?, derivationPath: Network.DerivationPath?): Int? {
private fun getAccountIndexOrNull(rawNetworkId: Network.RawID?, derivationPath: Network.DerivationPath?): Int? {
if (rawNetworkId == null || derivationPath == null) return null
val blockchain = Blockchain.fromId(id = rawNetworkId)
val blockchain = rawNetworkId.toBlockchain()
val recognizer = AccountNodeRecognizer(blockchain)
return recognizer.recognize(derivationPath)?.toInt()

View file

@ -0,0 +1,42 @@
package com.tangem.domain.account.status.utils
import com.tangem.domain.account.repository.AccountsExpandedRepository
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChooseTokenExpandedAccountsHolder @Inject constructor(
private val mainHolder: MainExpandedAccountsHolder,
holderFactory: DefaultExpandedAccountsHolder.Factory,
repositoryFactory: AccountsExpandedRepository.Factory,
) : ExpandedAccountsHolder {
private val repository: AccountsExpandedRepository =
repositoryFactory.create(AccountsExpandedRepository.CHOOSE_TOKEN_FILE_NAME)
private val defaultHolder: DefaultExpandedAccountsHolder = holderFactory.create(repository)
override fun expandedAccounts(walletId: UserWalletId): Flow<Set<AccountId>> = flow {
val isStored = repository.expandedAccounts.first()[walletId] != null
if (isStored) {
emitAll(defaultHolder.expandedAccounts(walletId))
} else {
val initExpanded = mainHolder.expandedAccounts(walletId).first()
emitAll(defaultHolder.expandedAccounts(walletId, initExpanded))
}
}
override fun expandAccount(accountId: AccountId) {
defaultHolder.expandAccount(accountId)
}
override fun collapseAccount(accountId: AccountId) {
defaultHolder.collapseAccount(accountId)
}
}

View file

@ -9,20 +9,26 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
// todo swap separate for main and swap
@Singleton
class ExpandedAccountsHolder @Inject constructor(
interface ExpandedAccountsHolder {
fun expandedAccounts(userWallet: UserWallet): Flow<Set<AccountId>> = expandedAccounts(userWallet.walletId)
fun expandedAccounts(walletId: UserWalletId): Flow<Set<AccountId>>
fun expandAccount(accountId: AccountId)
fun collapseAccount(accountId: AccountId)
}
class DefaultExpandedAccountsHolder @AssistedInject constructor(
private val singleAccountListSupplier: SingleAccountListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val accountsExpandedRepository: AccountsExpandedRepository,
@Assisted private val accountsExpandedRepository: AccountsExpandedRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
@ -31,71 +37,70 @@ class ExpandedAccountsHolder @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun expandedAccounts(userWallet: UserWallet): Flow<Set<AccountId>> = expandedAccounts(userWallet.walletId)
fun expandedAccounts(walletId: UserWalletId, initExpanded: Set<AccountId> = emptySet()): Flow<Set<AccountId>> =
channelFlow {
val storedState = accountsExpandedRepository.expandedAccounts
.map { it[walletId] ?: initExpanded.map { id -> AccountExpandedState(id, true) } }
.stateIn(this)
fun expandedAccounts(walletId: UserWalletId): Flow<Set<AccountId>> = channelFlow {
val storedState = accountsExpandedRepository.expandedAccounts
.map { it[walletId].orEmpty() }
.stateIn(this)
val isAccountsMode = isAccountsModeEnabledUseCase.invoke()
.stateIn(this)
val isAccountsMode = isAccountsModeEnabledUseCase.invoke()
.stateIn(this)
val initExpandedState = storedState.value
.mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId }
.toSet()
// main state holder
val expandedAccounts = MutableStateFlow(initExpandedState)
var debounceJob: Job? = null
val initExpandedState = storedState.value
.mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId }
.toSet()
// main state holder
val expandedAccounts = MutableStateFlow(initExpandedState)
var debounceJob: Job? = null
actionChannel
.filter { (accountId, _) -> accountId.userWalletId == walletId }
.filter { debounceJob?.isActive != true }
.onEach { (accountId, isExpand) ->
debounceJob = launch { delay(DEBOUNCE_MILLIS) }
val newState = AccountExpandedState(accountId, isExpand)
launch { accountsExpandedRepository.update(newState) }
if (isExpand) {
expandedAccounts.update { it.plus(accountId) }
} else {
expandedAccounts.update { it.minus(accountId) }
actionChannel
.filter { (accountId, _) -> accountId.userWalletId == walletId }
.filter { debounceJob?.isActive != true }
.onEach { (accountId, isExpand) ->
debounceJob = launch { delay(DEBOUNCE_MILLIS) }
val newState = AccountExpandedState(accountId, isExpand)
launch { accountsExpandedRepository.update(newState) }
if (isExpand) {
expandedAccounts.update { it.plus(accountId) }
} else {
expandedAccounts.update { it.minus(accountId) }
}
}
}
.launchIn(this)
.launchIn(this)
walletAccounts(walletId).onEach { accountList ->
if (!isAccountsModeEnabledUseCase.invokeSync()) {
accountsExpandedRepository.clearStore()
expandedAccounts.update { emptySet() }
return@onEach
}
val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId }
accountsExpandedRepository.syncStore(walletId, idsSet)
val isSingleAccount = accountList.accounts.size == 1
val storedMainAccountState = storedState.value
.find { it.accountId == accountList.mainAccount.accountId }
if (isSingleAccount && storedMainAccountState == null) {
// force expand for single and not stored account
expandedAccounts.update { setOf(accountList.mainAccount.accountId) }
}
}.launchIn(this)
combine(
flow = expandedAccounts,
flow2 = isAccountsMode,
transform = { expanded, isAccountMode ->
if (isAccountMode) {
channel.send(expanded)
} else {
channel.send(emptySet())
walletAccounts(walletId).onEach { accountList ->
if (!isAccountsModeEnabledUseCase.invokeSync()) {
accountsExpandedRepository.clearStore()
expandedAccounts.update { emptySet() }
return@onEach
}
},
).collect()
}
.flowOn(dispatchers.default)
.distinctUntilChanged()
val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId }
accountsExpandedRepository.syncStore(walletId, idsSet)
val isSingleAccount = accountList.accounts.size == 1
val storedMainAccountState = storedState.value
.find { it.accountId == accountList.mainAccount.accountId }
if (isSingleAccount && storedMainAccountState == null) {
// force expand for single and not stored account
expandedAccounts.update { setOf(accountList.mainAccount.accountId) }
}
}.launchIn(this)
combine(
flow = expandedAccounts,
flow2 = isAccountsMode,
transform = { expanded, isAccountMode ->
if (isAccountMode) {
channel.send(expanded)
} else {
channel.send(emptySet())
}
},
).collect()
}
.flowOn(dispatchers.default)
.distinctUntilChanged()
fun expandAccount(accountId: AccountId) {
actionChannel.tryEmit(accountId to true)
@ -107,6 +112,11 @@ class ExpandedAccountsHolder @Inject constructor(
private fun walletAccounts(walletId: UserWalletId): Flow<AccountList> = singleAccountListSupplier(walletId)
@AssistedFactory
interface Factory {
fun create(accountsExpandedRepository: AccountsExpandedRepository): DefaultExpandedAccountsHolder
}
companion object {
private const val DEBOUNCE_MILLIS = 200L
}

View file

@ -0,0 +1,31 @@
package com.tangem.domain.account.status.utils
import com.tangem.domain.account.repository.AccountsExpandedRepository
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MainExpandedAccountsHolder @Inject constructor(
holderFactory: DefaultExpandedAccountsHolder.Factory,
repositoryFactory: AccountsExpandedRepository.Factory,
) : ExpandedAccountsHolder {
private val repository: AccountsExpandedRepository = repositoryFactory
.create(AccountsExpandedRepository.MAIN_STORE_FILE_NAME)
private val default: DefaultExpandedAccountsHolder = holderFactory.create(repository)
override fun expandedAccounts(walletId: UserWalletId): Flow<Set<AccountId>> {
return default.expandedAccounts(walletId)
}
override fun expandAccount(accountId: AccountId) {
default.expandAccount(accountId)
}
override fun collapseAccount(accountId: AccountId) {
default.collapseAccount(accountId)
}
}

View file

@ -143,7 +143,7 @@ class ArchiveCryptoPortfolioUseCaseTest {
val defaultAddress = "0xABC"
val cryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.network.backendId } returns token.networkId
every { this@mockk.network.rawId } returns token.networkId
every { this@mockk.contractAddress } returns token.contractAddress!!
}

View file

@ -78,18 +78,6 @@ class IsAccountsModeEnabledUseCaseTest {
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is Locked`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Locked>())),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val actual = useCase.invoke().first()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns false when payment account is NotCreated`() = runTest {
val statusList = createAccountStatusList(
@ -222,18 +210,6 @@ class IsAccountsModeEnabledUseCaseTest {
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is Locked`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Locked>())),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns false when payment account is NotCreated`() = runTest {
val statusList = createAccountStatusList(

View file

@ -5,7 +5,7 @@ plugins {
}
android {
namespace = "com.tangem.domain.tokensync"
namespace = "com.tangem.domain.assetsdiscovery"
}
dependencies {
@ -14,6 +14,9 @@ dependencies {
implementation(projects.domain.account.status)
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.assetsdiscovery
import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryService
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
interface AssetsDiscoveryFacade {
suspend fun getAssetsDiscoveryService(userWalletId: UserWalletId, network: Network): AssetsDiscoveryServiceInfo?
data class AssetsDiscoveryServiceInfo(
val address: String,
val service: AssetsDiscoveryService,
)
}

View file

@ -1,13 +1,13 @@
package com.tangem.domain.tokensync.model
package com.tangem.domain.assetsdiscovery.model
sealed class TokenSyncProgress {
sealed class AssetsDiscoveryProgress {
data object Idle : TokenSyncProgress()
data object Idle : AssetsDiscoveryProgress()
data class InProgress(
val completedNetworks: Int,
val totalNetworks: Int,
) : TokenSyncProgress() {
) : AssetsDiscoveryProgress() {
val progressPercent: Int
get() = if (totalNetworks > 0) {
completedNetworks * 100 / totalNetworks
@ -16,5 +16,5 @@ sealed class TokenSyncProgress {
}
}
data object Completed : TokenSyncProgress()
data object Completed : AssetsDiscoveryProgress()
}

View file

@ -0,0 +1,25 @@
package com.tangem.domain.assetsdiscovery.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import kotlinx.coroutines.flow.Flow
interface AssetsDiscoveryRepository {
suspend fun runDiscovery(userWalletId: UserWalletId)
suspend fun completeDiscovery(userWalletId: UserWalletId)
suspend fun getPendingDiscoveryWalletIds(): List<UserWalletId>
fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow<AssetsDiscoveryProgress>
fun acknowledgeCompletion(userWalletId: UserWalletId)
suspend fun clearPendingFlag(userWalletId: UserWalletId)
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.assetsdiscovery.usecase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
class AcknowledgeAssetsDiscoveryCompletionUseCase(
private val assetsDiscoveryRepository: AssetsDiscoveryRepository,
) {
operator fun invoke(userWalletId: UserWalletId) {
assetsDiscoveryRepository.acknowledgeCompletion(userWalletId)
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.assetsdiscovery.usecase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import kotlinx.coroutines.flow.Flow
class ObserveAssetsDiscoveryUseCase(
private val assetsDiscoveryRepository: AssetsDiscoveryRepository,
) {
operator fun invoke(userWalletId: UserWalletId): Flow<AssetsDiscoveryProgress> {
return assetsDiscoveryRepository.observeDiscoveryProgress(userWalletId)
}
}

View file

@ -1,20 +1,23 @@
package com.tangem.domain.tokensync.usecase
package com.tangem.domain.assetsdiscovery.usecase
import arrow.core.Either
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
class StartTokenSyncUseCase(
private val tokenSyncRepository: TokenSyncRepository,
class StartAssetsDiscoveryUseCase(
private val assetsDiscoveryRepository: AssetsDiscoveryRepository,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val appCoroutineScope: AppCoroutineScope,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
private val activeSyncJobs = ConcurrentHashMap<UserWalletId, Job>()
@ -23,9 +26,11 @@ class StartTokenSyncUseCase(
activeSyncJobs[userWalletId]?.cancel()
activeSyncJobs[userWalletId] = appCoroutineScope.launch {
try {
tokenSyncRepository.runSync(userWalletId)
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted())
assetsDiscoveryRepository.runDiscovery(userWalletId)
applyDiscoveredTokens(userWalletId)
tokenSyncRepository.completeSync(userWalletId)
assetsDiscoveryRepository.completeDiscovery(userWalletId)
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted())
} catch (e: Exception) {
TangemLogger.e("Token sync failed for wallet: $userWalletId", e)
} finally {
@ -36,18 +41,18 @@ class StartTokenSyncUseCase(
suspend fun cancel(userWalletId: UserWalletId): Either<Throwable, Unit> = Either.catch {
activeSyncJobs.remove(userWalletId)?.cancel()
tokenSyncRepository.clearPendingFlag(userWalletId)
tokenSyncRepository.clearDiscoveredTokens(userWalletId)
assetsDiscoveryRepository.clearPendingFlag(userWalletId)
assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId)
}
fun applyPendingSyncs() {
fun applyPendingAssetsDiscovery() {
appCoroutineScope.launch {
try {
val pendingIds = tokenSyncRepository.getPendingSyncWalletIds()
val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds()
for (walletId in pendingIds) {
val isApplied = applyDiscoveredTokens(walletId)
if (isApplied) {
tokenSyncRepository.clearPendingFlag(walletId)
assetsDiscoveryRepository.clearPendingFlag(walletId)
}
}
} catch (e: Exception) {
@ -57,7 +62,7 @@ class StartTokenSyncUseCase(
}
private suspend fun applyDiscoveredTokens(userWalletId: UserWalletId): Boolean {
val currencies = tokenSyncRepository.getDiscoveredCurrencies(userWalletId)
val currencies = assetsDiscoveryRepository.getDiscoveredCurrencies(userWalletId)
if (currencies.isEmpty()) return true
@ -67,7 +72,7 @@ class StartTokenSyncUseCase(
add = currencies,
).fold(
ifRight = {
tokenSyncRepository.clearDiscoveredTokens(userWalletId)
assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId)
true
},
ifLeft = { error ->

View file

@ -33,4 +33,13 @@ sealed class TransactionParams {
data class Solana(
val transactions: List<String>,
) : TransactionParams()
/**
* Parameters for Bitcoin transactions
*
* @property params JSON-encoded transaction parameters
*/
data class Bitcoin(
val params: String,
) : TransactionParams()
}

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.card"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.core.analytics.models)
implementation(projects.core.error)
@ -25,6 +29,7 @@ dependencies {
implementation(projects.domain.visa.models)
implementation(projects.core.utils)
implementation(projects.libs.tangemSdkApi)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {
@ -32,9 +37,8 @@ dependencies {
}
/** Testing libraries */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testRuntimeOnly(deps.test.junit5.vintage.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -1,8 +1,33 @@
package com.tangem.domain.card
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.common.doOnFailure
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.logging.TangemLogger
interface DeleteSavedAccessCodesUseCase {
/**
* Removes saved user codes (access code and/or passcode) for a physical Tangem card
* from the device's secure storage.
*
* Typically invoked after a successful factory reset of the card, so that stale codes
* for an already-wiped card are not left on the device.
*
* @property tangemSdkManager Card SDK wrapper that performs the code removal operation
*/
class DeleteSavedAccessCodesUseCase(
private val tangemSdkManager: TangemSdkManager,
) {
suspend operator fun invoke(cardId: String): Either<Throwable, Unit>
/**
* @param cardId identifier of the card whose saved codes must be removed
* @return [Unit] on success; a Card SDK error (as [Throwable]) if removal failed
*/
suspend operator fun invoke(cardId: String): Either<Throwable, Unit> = either {
tangemSdkManager.deleteSavedUserCodes(cardsIds = setOf(cardId))
.doOnFailure { error ->
TangemLogger.e("Failed to delete saved access codes for card with id: $cardId", error)
raise(error)
}
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.domain.card
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
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 DeleteSavedAccessCodesUseCaseTest {
private val tangemSdkManager = mockk<TangemSdkManager>()
private lateinit var useCase: DeleteSavedAccessCodesUseCase
@BeforeEach
fun setup() {
clearMocks(tangemSdkManager)
useCase = DeleteSavedAccessCodesUseCase(tangemSdkManager = tangemSdkManager)
}
@Test
fun `returns Right Unit when sdk deletes codes successfully`() = runTest {
// Arrange
val cardId = "AA00000000000001"
coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Success(Unit)
// Act
val actual = useCase(cardId = cardId)
// Assert
assertEitherRight(actual)
}
@Test
fun `returns Left with sdk error when sdk fails`() = runTest {
// Arrange
val cardId = "AA00000000000002"
val sdkError = TangemSdkError.ExceptionError(RuntimeException("boom"))
coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Failure(sdkError)
// Act
val actual = useCase(cardId = cardId)
// Assert
assertEitherLeft(actual, sdkError)
}
@Test
fun `passes exactly the given cardId as a singleton set to sdk`() = runTest {
// Arrange
val cardId = "AA00000000000003"
coEvery { tangemSdkManager.deleteSavedUserCodes(any()) } returns CompletionResult.Success(Unit)
// Act
useCase(cardId = cardId)
// Assert
coVerify(exactly = 1) { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) }
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.common.wallets
import com.tangem.domain.models.wallet.UserWallet
/**
* Handler invoked when a user wallet becomes the active one.
*
* Side effects (analytics tracking context, Tangem SDK display config, access code request policy, etc.)
* follow switch-latest semantics: if a new selection arrives while a previous one is still being processed,
* the in-flight job is cancelled and only the latest selection is applied.
*/
interface UserWalletSelectedHandler {
suspend operator fun invoke(userWallet: UserWallet)
}

View file

@ -8,13 +8,25 @@ android {
namespace = "com.tangem.domain.dynamicaddresses"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
api(projects.domain.core)
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -11,7 +11,7 @@ class DisableDynamicAddressesUseCase(
/**
* Returns true when consolidation is required before disabling (non-base balances exist),
* or false when DA was disabled immediately.
* or false when dynamic addresses were disabled immediately.
*/
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
Either.catch {

View file

@ -0,0 +1,52 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationPath
/**
* Shared utility for checking if a derivation path conflicts with Dynamic Addresses.
*
* A path conflicts when it belongs to the same BIP44 account as the base path
* (first 3 nodes: purpose / coin_type / account match) but has non-zero
* change (node 3) or address_index (node 4).
*/
object DynamicAddressesDerivationChecker {
private const val BIP44_NODE_COUNT = 5
private const val ACCOUNT_NODE_COUNT = 3
private const val CHANGE_NODE_INDEX = 3
private const val ADDRESS_INDEX_NODE_INDEX = 4
/**
* @return `true` if [path] has zero change (node 3) and zero address_index (node 4).
*/
fun isBaseDerivation(path: String): Boolean {
val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false
if (nodes.size < BIP44_NODE_COUNT) return false
val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = nodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change == 0L && index == 0L
}
/**
* @return `true` if [customPath] shares the same account as [basePath] but has
* non-zero change or address_index nodes.
*/
fun hasSameAccountWithNonZeroChangeOrIndex(customPath: String, basePath: String): Boolean {
val customNodes = runCatching { DerivationPath(customPath).nodes }.getOrNull() ?: return false
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
if (customNodes.size < BIP44_NODE_COUNT || baseNodes.size < BIP44_NODE_COUNT) return false
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
customNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
}
if (!isSameAccount) return false
val change = customNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = customNodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change != 0L || index != 0L
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
/**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*
* Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported.
*/
object DynamicAddressesSupportedBlockchains {
private const val BIP44_PURPOSE = 44L
private const val BIP84_PURPOSE = 84L
private val supported = setOf(
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Litecoin,
Blockchain.Dogecoin,
Blockchain.Dash,
Blockchain.Ravencoin,
Blockchain.RavencoinTestnet,
)
private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet()
/**
* Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE)
}
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.dynamicaddresses
sealed class EnableDynamicAddressesError {
data object ConflictingCustomTokens : EnableDynamicAddressesError()
data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError()
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either<Throwable, Unit> =
Either.catch {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
xpub: String,
): Either<EnableDynamicAddressesError, Unit> {
return try {
if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) {
return EnableDynamicAddressesError.ConflictingCustomTokens.left()
}
dynamicAddressesRepository.enable(userWalletId, network, xpub)
Unit.right()
} catch (e: Throwable) {
EnableDynamicAddressesError.ServiceError(e).left()
}
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.calculateRipemd160
import com.tangem.common.extensions.calculateSha256
import com.tangem.crypto.NetworkType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
/**
* Returns the XPUB string if account-level keys are already derived (no card scan needed),
* or null if keys are not available.
*/
class GetDerivedXpubUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val derivationsRepository: DerivationsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): String? {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return null
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return null
val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return null
if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return null
val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey)
val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey)
val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT))
val parentPath = DerivationPath(accountPath.nodes.dropLast(1))
val childExtKey = existingKeys[accountPath] ?: return null
val parentExtKey = existingKeys[parentPath] ?: return null
val parentFingerprint = parentExtKey.publicKey
.calculateSha256().calculateRipemd160()
.take(PARENT_FINGERPRINT_SIZE).toByteArray()
val net = if (blockchain.isTestnet()) NetworkType.Testnet else NetworkType.Mainnet
return ExtendedPublicKey(
publicKey = childExtKey.publicKey,
chainCode = childExtKey.chainCode,
depth = accountPath.nodes.size,
parentFingerprint = parentFingerprint,
childNumber = accountPath.nodes.last().index,
).serialize(net)
}
private companion object {
const val ACCOUNT_PATH_DROP_COUNT = 2
const val PARENT_FINGERPRINT_SIZE = 4
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Checks if XPUB generation is supported for the given wallet and network (hardware capability check).
*/
class IsXpubSupportedUseCase(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
return walletManager.wallet.publicKey.derivationType?.hdKey != null
}
}

View file

@ -19,4 +19,18 @@ interface DynamicAddressesRepository {
suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String?
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
/** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */
fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow<Boolean>
/**
* Emits true when dynamic addresses are DISABLED for this token but a silent xpub probe
* detected non-zero balances on derived addresses beyond the base one. Only positive
* results are cached per session; false results and probe failures are not cached and may
* be re-probed on subsequent collections or when status changes.
*/
fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow<Boolean>
}

View file

@ -0,0 +1,203 @@
package com.tangem.domain.dynamicaddresses
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DynamicAddressesDerivationCheckerTest {
// region Conflicting: same account, non-zero change or index
@Test
fun `same account, non-zero address index`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, non-zero change`() {
val result = check(custom = "m/44'/5'/0'/1/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, both change and index non-zero`() {
val result = check(custom = "m/44'/5'/0'/1/5", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, large address index`() {
val result = check(custom = "m/44'/5'/0'/0/8", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `bitcoin BIP84, non-zero index`() {
val result = check(custom = "m/84'/0'/0'/0/1", base = "m/84'/0'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `litecoin BIP84, non-zero change`() {
val result = check(custom = "m/84'/2'/0'/1/0", base = "m/84'/2'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `dogecoin, non-zero index`() {
val result = check(custom = "m/44'/3'/0'/0/8", base = "m/44'/3'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `bitcoin cash, non-zero index`() {
val result = check(custom = "m/44'/145'/0'/0/3", base = "m/44'/145'/0'/0/0")
assertThat(result).isTrue()
}
// endregion
// region Not conflicting: different account
@Test
fun `different account index, non-zero address index`() {
val result = check(custom = "m/44'/5'/1'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different account index, zero change and index`() {
val result = check(custom = "m/44'/5'/1'/0/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different coin type`() {
val result = check(custom = "m/44'/0'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different purpose`() {
val result = check(custom = "m/84'/5'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `BIP44 custom against BIP84 base`() {
val result = check(custom = "m/44'/0'/0'/0/1", base = "m/84'/0'/0'/0/0")
assertThat(result).isFalse()
}
// endregion
// region Not conflicting: same account, zero change and index
@Test
fun `identical paths`() {
val result = check(custom = "m/44'/5'/0'/0/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `identical bitcoin BIP84 paths`() {
val result = check(custom = "m/84'/0'/0'/0/0", base = "m/84'/0'/0'/0/0")
assertThat(result).isFalse()
}
// endregion
// region Edge cases: invalid or incomplete paths
@Test
fun `invalid custom path`() {
val result = check(custom = "invalid", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `invalid base path`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "not_a_path")
assertThat(result).isFalse()
}
@Test
fun `empty custom path`() {
val result = check(custom = "", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `empty base path`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "")
assertThat(result).isFalse()
}
@Test
fun `both paths invalid`() {
val result = check(custom = "abc", base = "xyz")
assertThat(result).isFalse()
}
@Test
fun `custom path with fewer than 5 nodes`() {
val result = check(custom = "m/44'/5'/0'", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `base path with fewer than 5 nodes`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'")
assertThat(result).isFalse()
}
// endregion
// region isBaseDerivation
@Test
fun `isBaseDerivation - standard BIP44 base path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - BIP84 base path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/84'/0'/0'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - non-zero index`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/1")).isFalse()
}
@Test
fun `isBaseDerivation - non-zero change`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/1/0")).isFalse()
}
@Test
fun `isBaseDerivation - non-zero account with zero change and index`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/2'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - invalid path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("invalid")).isFalse()
}
@Test
fun `isBaseDerivation - too few nodes`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'")).isFalse()
}
// endregion
private fun check(custom: String, base: String): Boolean {
return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
customPath = custom,
basePath = base,
)
}
}

View file

@ -56,7 +56,7 @@ class GetEarnNetworksUseCase(
accountLists
.filter { it.userWalletId in unlockedWalletsId }
.flatMap(AccountList::flattenCurrencies)
.mapTo(HashSet()) { it.network.backendId }
.mapTo(HashSet()) { it.network.rawId }
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.express.models
import java.math.BigDecimal
@Suppress("MagicNumber")
sealed class ExpressError : Throwable() {
abstract val code: Int
@ -61,4 +62,12 @@ sealed class ExpressError : Throwable() {
data object UnknownError : ExpressError() {
override val code: Int = -1
}
data class TooLargeSolanaTransactionError(override val code: Int = -2) : ExpressError() {
override val message: String = "tooLargeSolanaTransaction"
}
data class DexActiveSupplyError(override val code: Int = -3) : ExpressError() {
override val message: String = "dexActiveSupplyError"
}
}

View file

@ -23,14 +23,18 @@ enum class ExpressProviderType(val typeName: String) {
ONRAMP(typeName = "ONRAMP"),
;
fun shouldStoreSwapTransaction() = when (this) {
CEX,
DEX_BRIDGE,
DEX,
-> true
ONRAMP,
-> false
}
companion object {
fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) {
CEX,
DEX_BRIDGE,
-> true
DEX,
ONRAMP,
-> false
fun getSwapProviderTypes(): List<ExpressProviderType> {
return listOf(CEX, DEX, DEX_BRIDGE)
}
}
}

View file

@ -1,11 +1,12 @@
package com.tangem.domain.feedback.models
import com.tangem.domain.models.network.Network
/**
* Information about blockchain's operation error
*
* @property errorMessage message about error
* @property blockchainId blockchain id
* @property derivationPath derivation path
* @property networkId network ID
* @property destinationAddress destination address
* @property tokenSymbol token symbol or null, if it isn't operation with token
* @property amount amount
@ -13,8 +14,7 @@ package com.tangem.domain.feedback.models
*/
data class BlockchainErrorInfo(
val errorMessage: String,
val blockchainId: String,
val derivationPath: String?,
val networkId: Network.ID?,
val destinationAddress: String,
val tokenSymbol: String?,
val amount: String,

View file

@ -1,6 +1,7 @@
package com.tangem.domain.feedback.repository
import com.tangem.domain.feedback.models.*
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import java.io.File
@ -17,11 +18,7 @@ interface FeedbackRepository {
fun getPhoneInfo(): PhoneInfo
suspend fun getBlockchainInfo(
userWalletId: UserWalletId,
blockchainId: String,
derivationPath: String?,
): BlockchainInfo?
suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo?
fun saveBlockchainErrorInfo(error: BlockchainErrorInfo)

View file

@ -94,11 +94,10 @@ class EmailMessageBodyResolver(
val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
val blockchainInfo = blockchainError?.networkId?.let { networkId ->
feedbackRepository.getBlockchainInfo(
userWalletId = userWalletId,
blockchainId = blockchainError.blockchainId,
derivationPath = blockchainError.derivationPath,
networkId = networkId,
)
}
@ -159,11 +158,10 @@ class EmailMessageBodyResolver(
val userWalletId = requireNotNull(walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
val blockchainInfo = blockchainError?.networkId?.let { networkId ->
feedbackRepository.getBlockchainInfo(
userWalletId = userWalletId,
blockchainId = blockchainError.blockchainId,
derivationPath = blockchainError.derivationPath,
networkId = networkId,
)
}
@ -181,11 +179,10 @@ class EmailMessageBodyResolver(
val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
val blockchainInfo = blockchainError?.networkId?.let { networkId ->
feedbackRepository.getBlockchainInfo(
userWalletId = userWalletId,
blockchainId = blockchainError.blockchainId,
derivationPath = blockchainError.derivationPath,
networkId = networkId,
)
}
@ -210,11 +207,10 @@ class EmailMessageBodyResolver(
val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
val blockchainInfo = blockchainError?.networkId?.let { networkId ->
feedbackRepository.getBlockchainInfo(
userWalletId = userWalletId,
blockchainId = blockchainError.blockchainId,
derivationPath = blockchainError.derivationPath,
networkId = networkId,
)
}

View file

@ -42,7 +42,6 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.reKotlin)
ksp(deps.moshi.kotlin.codegen)
/** Testing libraries */

View file

@ -15,7 +15,6 @@ object NetworkLogConfig {
object AnalyticsHandlersLogConfig {
val isFirebaseLogEnabled: Boolean = BuildConfig.LOG_ENABLED
val isAmplitudeLogEnabled: Boolean = BuildConfig.LOG_ENABLED
val isAppsflyerLogEnabled: Boolean = BuildConfig.LOG_ENABLED
val isCustomerIoLogEnabled: Boolean = BuildConfig.LOG_ENABLED
}

View file

@ -31,11 +31,22 @@ interface RampStateManager {
sendUnavailabilityReason: ScenarioUnavailabilityReason?,
): Either<ScenarioUnavailabilityReason, Unit>
/**
* Check if [CryptoCurrency] is available for swap (express/assets request)
*
* @param userWalletId the ID of the user's wallet
* @param cryptoCurrency cryptocurrency
*/
suspend fun availableForSwap(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): ScenarioUnavailabilityReason
suspend fun availableForSwap(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Map<CryptoCurrency, ScenarioUnavailabilityReason>
suspend fun fetchSellServiceData()
fun getSellInitializationStatus(): Flow<Lce<Throwable, Any>>

View file

@ -1,8 +0,0 @@
package com.tangem.domain.redux
import org.rekotlin.Action
sealed interface LegacyAction : Action {
data object PrepareDetailsScreen : LegacyAction
}

View file

@ -1,13 +0,0 @@
package com.tangem.domain.redux
import com.tangem.domain.models.wallet.UserWallet
import org.rekotlin.Action
interface ReduxStateHolder {
fun dispatch(action: Action)
suspend fun dispatchWithMain(action: Action)
suspend fun onUserWalletSelected(userWallet: UserWallet)
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.markets
import kotlinx.serialization.Serializable
@Serializable
enum class PreselectedMarketsInterval(val value: String) {
H24("24h"),
W1("1w"),
D30("30d"),
;
companion object {
fun parse(value: String?): PreselectedMarketsInterval? = entries.firstOrNull { it.value == value }
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.markets
import kotlinx.serialization.Serializable
@Serializable
enum class PreselectedMarketsOrder(val value: String) {
Rating("rating"),
Trending("trending"),
Buyers("buyers"),
Gainers("gainers"),
Losers("losers"),
;
companion object {
fun parse(value: String?): PreselectedMarketsOrder? = entries.firstOrNull { it.value == value }
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.markets
import kotlinx.serialization.Serializable
@Serializable
enum class PreselectedTokenDetailsSection(val value: String) {
News("news"),
;
companion object {
fun parse(value: String?): PreselectedTokenDetailsSection? = entries.firstOrNull { it.value == value }
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.markets
import com.tangem.domain.models.currency.CryptoCurrency
/**
* minimal token info for add-to-portfolio flow
*/
data class RawMarketToken(
val id: CryptoCurrency.RawID,
val name: String,
val symbol: String,
)

View file

@ -10,7 +10,7 @@ class GetTokenMarketCryptoCurrency(
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
tokenMarketParams: TokenMarketParams,
tokenMarketParams: RawMarketToken,
network: TokenMarketInfo.Network,
accountIndex: DerivationIndex,
): CryptoCurrency? {

View file

@ -42,7 +42,7 @@ interface MarketsTokenRepository {
suspend fun createCryptoCurrency(
userWalletId: UserWalletId,
token: TokenMarketParams,
token: RawMarketToken,
network: TokenMarketInfo.Network,
accountIndex: DerivationIndex? = null,
): CryptoCurrency?

View file

@ -18,11 +18,11 @@ data class TokenReceiveConfig(
@Serializable
data class ReceiveAddressModel(
val nameService: NameService,
val displayType: DisplayType,
val value: String,
) {
enum class NameService {
Default, Legacy, Ens
enum class DisplayType {
Default, Legacy, Ens, Dynamic,
}
}

View file

@ -37,6 +37,8 @@ data class AccountId private constructor(
companion object {
const val PaymentAccountIdPrefix = "payment_"
private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") }
private val hexRegex = Regex("^[a-fA-F0-9]{64}$")
@ -73,7 +75,7 @@ data class AccountId private constructor(
}
fun forPaymentAccount(userWalletId: UserWalletId): AccountId {
return AccountId(value = "payment_$userWalletId", userWalletId = userWalletId)
return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId)
}
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.domain.models.account
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import kotlinx.serialization.Serializable
@Serializable
@ConsistentCopyVisibility
data class CardDisplayName private constructor(val value: String) {
@Serializable
sealed interface Error {
@Serializable
data object Empty : Error
@Serializable
data object ExceedsMaxLength : Error
@Serializable
data object InvalidCharacters : Error
}
companion object {
const val MAX_LENGTH = 20
private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$")
operator fun invoke(name: String): Either<Error, CardDisplayName> = either {
val trimmed = name.trim()
ensure(trimmed.isNotEmpty()) { Error.Empty }
ensure(trimmed.length <= MAX_LENGTH) { Error.ExceedsMaxLength }
ensure(allowedPattern.matches(trimmed)) { Error.InvalidCharacters }
CardDisplayName(trimmed)
}
}
}

View file

@ -2,9 +2,15 @@ package com.tangem.domain.models.account
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.PaymentAccountStatusValue.Loaded
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
import java.math.BigDecimal
/**
* Represents the various states a payment account can have, encapsulating different information based on the state.
@ -25,7 +31,6 @@ sealed class PaymentAccountStatusValue {
is UnderReview,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Locked -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
}
@ -38,7 +43,6 @@ sealed class PaymentAccountStatusValue {
return when (this) {
is IssuingCard -> copy(source = source)
is Loaded -> copy(source = source)
is Locked -> copy(source = source)
is UnderReview -> copy(source = source)
is Loading,
is Empty,
@ -88,57 +92,49 @@ sealed class PaymentAccountStatusValue {
@Serializable
data class IssuingCard(override val source: StatusSource) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is locked.
*
* @property source The source of the status information.
* @property customerId The unique identifier of the customer.
* @property cardId The unique identifier of the card.
* @property lastFourDigits The last four digits of the card number.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available.
* @property isPinSet Indicates if the PIN is set for the card.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
*/
@Serializable
data class Locked(
override val source: StatusSource,
val customerId: String,
val cardId: String,
val lastFourDigits: String,
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is successfully loaded with complete information.
*
* @property source The source of the status information.
* @property customerId The unique identifier of the customer.
* @property cardId The unique identifier of the card.
* @property lastFourDigits The last four digits of the card number.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available.
* @property isPinSet Indicates if the PIN is set for the card.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property cards The list of user's cards.
*/
@Serializable
data class Loaded(
override val source: StatusSource,
val customerId: String,
val cardId: String,
val lastFourDigits: String,
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
val cryptoCurrency: CryptoCurrency.Token,
val cards: List<TangemPayCard>,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
),
)
}
/** Represents an error state for the payment account status. */
@Serializable
@ -198,4 +194,10 @@ sealed class PaymentAccountStatusValue {
val tokenContractAddress: String,
val balance: SerializedBigDecimal,
)
}
}
fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId }
fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId }
fun Loaded.requireCardWithId(cardId: String): TangemPayCard = requireNotNull(findCardWithId(cardId))

View file

@ -3,7 +3,7 @@ package com.tangem.domain.models.currency
import java.math.BigDecimal
fun CryptoCurrency.Token.yieldSupplyKey(): String {
return "${network.backendId}_$contractAddress"
return "${network.rawId}_$contractAddress"
}
fun CryptoCurrencyStatus.hasNotSuppliedAmount(): Boolean {

View file

@ -10,7 +10,6 @@ import kotlinx.serialization.Serializable
* (e.g., ERC20, BEP20).
*
* @property id the unique identifier of the network
* @property backendId the name of this network in the Tangem backend
* @property name the human-readable name of the network, such as "Ethereum" or "Bitcoin"
* @property currencySymbol the symbol of the currency associated with the network
* @property derivationPath the path used to derive keys for this network
@ -25,7 +24,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class Network(
val id: ID,
val backendId: String,
val name: String,
val currencySymbol: String,
val derivationPath: DerivationPath,
@ -49,7 +47,7 @@ data class Network(
/**
* Represents a unique identifier for a blockchain network
*
* @property rawId raw network ID
* @property rawId raw network ID (backend id)
* @property derivationPath derivation path
*/
@Serializable

View file

@ -0,0 +1,25 @@
package com.tangem.domain.models.pay
import com.tangem.domain.models.account.CardDisplayName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a Tangem Pay card linked to a payment account.
*
* @property id unique card identifier assigned by the backend.
* @property hasPinCode whether the card has a PIN code set.
* @property displayName optional human-readable name assigned to the card; `null` if not set.
* @property limit spending limit configuration for the card; `null` if not configured or not yet loaded.
* @property isFrozen whether the card is currently frozen (blocked for payments).
* @property lastDigits The last four digits of the card number.
*/
@Serializable
data class TangemPayCard(
@SerialName("id") val id: String,
@SerialName("has_pin_code") val hasPinCode: Boolean,
@SerialName("display_name") val displayName: CardDisplayName?,
@SerialName("limit") val limit: TangemPayCardLimitData?,
@SerialName("is_frozen") val isFrozen: Boolean,
@SerialName("last_digits") val lastDigits: String,
)

View file

@ -0,0 +1,49 @@
package com.tangem.domain.models.pay
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
data class TangemPayCardLimit(
@SerialName("amount") val amount: SerializedBigDecimal,
@SerialName("period") val period: TangemPayCardLimitPeriod,
)
@Serializable
enum class TangemPayCardLimitPeriod {
@SerialName("DAY")
DAY,
@SerialName("WEEK")
WEEK,
@SerialName("MONTH")
MONTH,
@SerialName("YEAR")
YEAR,
@SerialName("ALL_TIME")
ALL_TIME,
@SerialName("AUTHORIZATION")
AUTHORIZATION,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String) = when (value.uppercase(Locale.US)) {
"DAY" -> DAY
"WEEK" -> WEEK
"MONTH" -> MONTH
"YEAR" -> YEAR
"ALL_TIME" -> ALL_TIME
"AUTHORIZATION" -> AUTHORIZATION
else -> UNKNOWN
}
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.models.pay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TangemPayCardLimitData(
@SerialName("actual_card_limit") val actualCardLimit: TangemPayCardLimit?,
@SerialName("admin_card_limit") val adminCardLimit: TangemPayCardLimit?,
)

View file

@ -1,4 +1,4 @@
package com.tangem.domain.models
package com.tangem.domain.models.pay
enum class TangemPayEligibilityType {

View file

@ -0,0 +1,8 @@
package com.tangem.domain.models.pay
import java.math.BigDecimal
data class TangemPayReissueCardFee(
val amount: BigDecimal,
val currencyCode: String,
)

View file

@ -1,14 +1,16 @@
package com.tangem.domain.search.model
package com.tangem.domain.models.portfolio
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
data class UserAssetSearchEntry(
data class UserAssetEntry(
val userWalletId: UserWalletId,
val userWalletName: String,
val accountId: AccountId,
val accountName: AccountName,
val accountIcon: CryptoPortfolioIcon,
val currencyStatus: CryptoCurrencyStatus,
)

View file

@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>UnnecessaryAbstractClass:MultiNetworkStatusSupplier.kt$MultiNetworkStatusSupplier$MultiNetworkStatusSupplier</ID>
<ID>UnnecessaryAbstractClass:SingleNetworkStatusSupplier.kt$SingleNetworkStatusSupplier$SingleNetworkStatusSupplier</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus
*
[REDACTED_AUTHOR]
*/
abstract class MultiNetworkStatusSupplier(
open class MultiNetworkStatusSupplier(
override val factory: MultiNetworkStatusProducer.Factory,
override val keyCreator: (MultiNetworkStatusProducer.Params) -> String,
) : FlowCachingSupplier<MultiNetworkStatusProducer, MultiNetworkStatusProducer.Params, Set<NetworkStatus>>()

View file

@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus
*
[REDACTED_AUTHOR]
*/
abstract class SingleNetworkStatusSupplier(
open class SingleNetworkStatusSupplier(
override val factory: SingleNetworkStatusProducer.Factory,
override val keyCreator: (SingleNetworkStatusProducer.Params) -> String,
) : FlowCachingSupplier<SingleNetworkStatusProducer, SingleNetworkStatusProducer.Params, NetworkStatus>()

View file

@ -7,14 +7,12 @@ import kotlinx.serialization.Serializable
*
[REDACTED_AUTHOR]
*
* @param language device locale (ex: en, ru).
* @param snapshot id snapshot (`meta.asOf`) to stabilize responses.
* @param tokenIds filter by tokens.
* @param categoryIds filter by category.
*/
@Serializable
data class NewsListConfig(
val language: String,
val snapshot: String?,
val tokenIds: List<String> = emptyList(),
val categoryIds: List<Int> = emptyList(),

View file

@ -38,15 +38,14 @@ interface NewsRepository {
/**
* Fetches and caches detailed articles for provided ids in parallel.
*/
suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?): Either<Map<Int, Throwable>, Unit>
suspend fun fetchDetailedArticles(newsIds: Collection<Int>): Either<Map<Int, Throwable>, Unit>
/**
* Fetch list of trending news by limit and with correct locale and store it in runtime data store.
*
* @param limit
* @param language current device locale
*/
suspend fun fetchTrendingNews(limit: Int, language: String?)
suspend fun fetchTrendingNews(limit: Int)
/**
* Observes trending news with runtime viewed flag support.

View file

@ -2,7 +2,6 @@ package com.tangem.domain.news.usecase
import arrow.core.Either
import com.tangem.domain.news.repository.NewsRepository
import java.util.Locale
/**
* Fetches trending news to store it in runtime data store.
@ -11,10 +10,7 @@ import java.util.Locale
class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) {
suspend operator fun invoke(): Either<Throwable, Unit> = Either.catch {
newsRepository.fetchTrendingNews(
limit = LIMIT_FOR_TRENDING_NEWS,
language = Locale.getDefault().language,
)
newsRepository.fetchTrendingNews(limit = LIMIT_FOR_TRENDING_NEWS)
}
companion object {

View file

@ -24,6 +24,6 @@ class ObserveNewsDetailsUseCase(
/**
* Prefetches the given article ids (can be called with current + next ids for pager preloading).
*/
suspend fun prefetch(newsIds: Collection<Int>, language: String?): Either<Map<Int, Throwable>, Unit> =
repository.fetchDetailedArticles(newsIds, language)
suspend fun prefetch(newsIds: Collection<Int>): Either<Map<Int, Throwable>, Unit> =
repository.fetchDetailedArticles(newsIds)
}

View file

@ -3,5 +3,5 @@ package com.tangem.domain.search.model
data class SearchResult(
val textHints: List<SearchTextHint>,
val recentTokens: List<RecentSearchToken>,
val userAssets: List<UserAssetSearchEntry>,
val userAssets: List<UserAssetSearchItem>,
)

View file

@ -0,0 +1,15 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.portfolio.UserAssetEntry
sealed interface UserAssetSearchItem {
data class Single(val entry: UserAssetEntry) : UserAssetSearchItem
data class Grouped(
val tokenName: String,
val tokenSymbol: String,
val tokenIconUrl: String?,
val entries: List<UserAssetEntry>,
) : UserAssetSearchItem
}

View file

@ -8,11 +8,13 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.search.model.SearchResult
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.repository.SearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
/**
* Primary search use case that produces [SearchResult] based on the current query.
@ -70,9 +72,12 @@ class GetSearchResultsUseCase(
if (unlockedWallets.isEmpty()) return@combine emptyList()
statusLists
val entries = statusLists
.filter { it.userWalletId in unlockedWallets }
.flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) }
val shouldGroup = needsGrouping(unlockedWallets.values, statusLists)
groupAndSort(entries, shouldGroup)
}.map { userAssets ->
SearchResult(
textHints = emptyList(),
@ -82,11 +87,49 @@ class GetSearchResultsUseCase(
}
}
private fun needsGrouping(unlockedWallets: Collection<UserWallet>, statusLists: List<AccountStatusList>): Boolean {
if (unlockedWallets.size > 1) return true
val totalAccounts = statusLists
.filter { sl -> unlockedWallets.any { it.walletId == sl.userWalletId } }
.sumOf { it.accountStatuses.filterCryptoPortfolio().size }
return totalAccounts > 1
}
private fun groupAndSort(entries: List<UserAssetEntry>, shouldGroup: Boolean): List<UserAssetSearchItem> {
if (!shouldGroup) {
return entries
.map { UserAssetSearchItem.Single(it) }
.sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
}
val grouped = entries.groupBy { entry ->
val rawId = entry.currencyStatus.currency.id.rawCurrencyId
rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}"
}
return grouped.map { (_, groupEntries) ->
val assetInfo = groupEntries.first()
UserAssetSearchItem.Grouped(
tokenName = assetInfo.currencyStatus.currency.name,
tokenSymbol = assetInfo.currencyStatus.currency.symbol,
tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl,
entries = groupEntries,
)
}.sortedByDescending { item ->
when (item) {
is UserAssetSearchItem.Grouped ->
item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
}
}
}
private fun extractMatchingAssets(
statusList: AccountStatusList,
wallets: Map<UserWalletId, UserWallet>,
lowerQuery: String,
): List<UserAssetSearchEntry> {
): List<UserAssetEntry> {
val wallet = wallets[statusList.userWalletId] ?: return emptyList()
return statusList.accountStatuses
.filterCryptoPortfolio()
@ -98,11 +141,12 @@ class GetSearchResultsUseCase(
name.contains(lowerQuery) || symbol.contains(lowerQuery)
}
.map { currencyStatus ->
UserAssetSearchEntry(
UserAssetEntry(
userWalletId = statusList.userWalletId,
userWalletName = wallet.name,
accountId = accountStatus.accountId,
accountName = accountStatus.account.accountName,
accountIcon = accountStatus.account.icon,
currencyStatus = currencyStatus,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.settings
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the hot wallet creation restriction setting.
*
* When the restriction is enabled, users are forced to scan a physical Tangem card
* instead of being able to create a new software (hot) wallet.
*/
interface HotWalletRestrictionManager {
/** Observes the current restriction state as a [StateFlow]. */
fun isCreationEnabled(): StateFlow<Boolean>
/** Returns the latest cached restriction state synchronously. */
fun isCreationEnabledSync(): Boolean
/** Toggles the restriction state. No-op in production. */
suspend fun toggleCreationEnabled()
}

View file

@ -1,13 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -&gt; raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -&gt; Unit.right() } return@either }</ID>
<ID>MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending &amp;&amp; action.amount &lt; it.amount &amp;&amp; it.type == BalanceType.STAKED &amp;&amp; it.validatorAddress == action.validatorAddress }</ID>
<ID>NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId)</ID>
<ID>UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier</ID>
<ID>UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier</ID>
<ID>UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf()</ID>
<ID>UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: ""</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -23,9 +23,9 @@ class FetchStakingYieldBalanceUseCase(
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
)
.getOrElse {
when (it) {
is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it"))
.getOrElse { error ->
when (error) {
is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$error"))
StakingIdFactory.Error.UnsupportedCurrency -> Unit.right()
}

View file

@ -4,10 +4,11 @@ import arrow.core.Either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.network.Network
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakeKitRepository
import com.tangem.domain.staking.repositories.StakingErrorResolver
class GetConstructedStakingTransactionUseCase(
private val stakeKitRepository: StakeKitRepository,
@ -15,12 +16,17 @@ class GetConstructedStakingTransactionUseCase(
) {
suspend operator fun invoke(
networkId: String,
networkId: Network.RawID,
fee: Fee,
amount: Amount,
transactionId: String,
): Either<StakingError, Pair<StakingTransaction, TransactionData.Compiled>> = Either.catch {
stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId)
stakeKitRepository.constructTransaction(
networkId = networkId,
fee = fee,
amount = amount,
transactionId = transactionId,
)
}.mapLeft {
stakingErrorResolver.resolve(it)
}

View file

@ -100,7 +100,7 @@ class InvalidatePendingTransactionsUseCase(
type = BalanceType.STAKED,
amount = action.amount,
rawCurrencyId = null,
validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "",
validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0).orEmpty(),
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
@ -149,10 +149,10 @@ class InvalidatePendingTransactionsUseCase(
}
private fun findPartialUnstake(balances: MutableList<BalanceItem>, action: StakingAction): Pair<Int, BigDecimal> {
val index = balances.indexOfFirst {
!it.isPending && action.amount < it.amount &&
it.type == BalanceType.STAKED &&
it.validatorAddress == action.validatorAddress
val index = balances.indexOfFirst { balance ->
!balance.isPending && action.amount < balance.amount &&
balance.type == BalanceType.STAKED &&
balance.validatorAddress == action.validatorAddress
}
return index to action.amount
}

View file

@ -2,23 +2,27 @@ package com.tangem.domain.staking
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
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.models.staking.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Factory class for creating instances of [StakingID]
*
* @property walletManagersFacade wallet manager facade
* @property walletManagersFacade wallet manager facade
* @property stakingFeatureToggles staking feature toggles
*
[REDACTED_AUTHOR]
*/
class StakingIdFactory(
private val walletManagersFacade: WalletManagersFacade,
private val stakingFeatureToggles: StakingFeatureToggles,
) {
/**
@ -72,6 +76,8 @@ class StakingIdFactory(
ensureNotNull(integrationId) { Error.UnsupportedCurrency }
ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency }
val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() }
ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) }

View file

@ -8,7 +8,7 @@ import com.tangem.domain.models.staking.action.StakingActionType
sealed class StakingAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(
category = "Staking",
event = event,

View file

@ -147,7 +147,7 @@ sealed interface StakingIntegrationID {
* @return a [StakingIntegrationID] if supported, or `null` if not supported.
*/
fun create(currencyId: CryptoCurrency.ID): StakingIntegrationID? {
val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId)
val blockchain = currencyId.toBlockchain()
return if (currencyId.contractAddress.isNullOrBlank()) {
// Order is not important — either P2PEthPool or Stakekit.Coin can be in any order

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance
*
[REDACTED_AUTHOR]
*/
abstract class MultiStakingBalanceSupplier(
open class MultiStakingBalanceSupplier(
override val factory: FlowProducer.Factory<MultiStakingBalanceProducer.Params, MultiStakingBalanceProducer>,
override val keyCreator: (MultiStakingBalanceProducer.Params) -> String,
) : FlowCachingSupplier<MultiStakingBalanceProducer, MultiStakingBalanceProducer.Params, Set<StakingBalance>>()

View file

@ -5,11 +5,11 @@ import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
@ -55,7 +55,7 @@ interface StakeKitRepository {
suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate
suspend fun constructTransaction(
networkId: String,
networkId: Network.RawID,
fee: Fee,
amount: Amount,
transactionId: String,

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance
*
[REDACTED_AUTHOR]
*/
abstract class SingleStakingBalanceSupplier(
open class SingleStakingBalanceSupplier(
override val factory: FlowProducer.Factory<SingleStakingBalanceProducer.Params, SingleStakingBalanceProducer>,
override val keyCreator: (SingleStakingBalanceProducer.Params) -> String,
) : FlowCachingSupplier<SingleStakingBalanceProducer, SingleStakingBalanceProducer.Params, StakingBalance>()

View file

@ -1,5 +1,8 @@
package com.tangem.domain.staking.toggles
import com.tangem.domain.staking.model.StakingIntegrationID
interface StakingFeatureToggles {
val isEthStakingEnabled: Boolean
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
}

View file

@ -5,17 +5,16 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@ -30,11 +29,16 @@ import org.junit.jupiter.params.ParameterizedTest
internal class StakingIdFactoryTest {
private val walletManagersFacade: WalletManagersFacade = mockk()
private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade)
private val stakingFeatureToggles: StakingFeatureToggles = mockk()
private val factory = StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
@BeforeEach
fun resetMocks() {
clearMocks(walletManagersFacade)
clearMocks(walletManagersFacade, stakingFeatureToggles)
every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true
}
@Nested
@ -66,6 +70,33 @@ internal class StakingIdFactoryTest {
}
}
@Test
fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest {
// Arrange
val userWalletId = UserWalletId(stringValue = "011")
val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
every {
stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton)
} returns false
// Act
val actual = factory.create(
userWalletId = userWalletId,
currencyId = currency.id,
network = currency.network,
)
// Assert
val expected = StakingIdFactory.Error.UnsupportedCurrency
Truth.assertThat(actual.leftOrNull()).isEqualTo(expected)
coVerify(inverse = true) {
walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any())
}
}
@Test
fun `create returns UnableToGetAddress if address is null`() = runTest {
// Arrange
@ -154,7 +185,7 @@ internal class StakingIdFactoryTest {
),
CreateModel(
currencyId = CryptoCurrency.ID.fromValue(
value = "token⟨ETH⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0",
value = "token⟨ethereum⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0",
),
expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon),
),
@ -168,6 +199,6 @@ internal class StakingIdFactoryTest {
data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: Either<StakingIdFactory.Error, StakingID>)
private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID {
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}")
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}")
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.staking
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.staking.model.StakingApproval
import com.tangem.domain.staking.model.StakingIntegrationID
@ -144,11 +145,11 @@ class StakingIntegrationIDTest {
expected = StakingIntegrationID.P2PEthPool,
),
CreateModel(
currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩polygon-ecosystem-token⚓1234567890"),
currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩polygon-ecosystem-token⚓1234567890"),
expected = StakingIntegrationID.StakeKit.EthereumToken.Polygon,
),
CreateModel(
currencyId = CryptoCurrency.ID.fromValue(value = "token⟨SOLANA⟩solana⚓1234567890"),
currencyId = CryptoCurrency.ID.fromValue(value = "token⟨solana⟩solana⚓1234567890"),
expected = null,
),
)
@ -157,6 +158,6 @@ class StakingIntegrationIDTest {
data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingIntegrationID?)
private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID {
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}")
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}")
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.domain.swap.models
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
/**
* Represents the status of a cryptocurrency in the context of a swap operation.
*
* Combines the [UserWallet], [CryptoCurrencyStatus], and [Account] to provide
* all necessary information about a currency participating in a swap.
*
* @property userWallet the user wallet that owns the currency
* @property status the current status of the cryptocurrency, including balance and value state
* @property account the account within the wallet that holds the currency
* @property currency shortcut to the [CryptoCurrency] from [status]
* @property userWalletId shortcut to the wallet ID from [userWallet]
* @property isAvailableForSwap whether this currency can participate in a swap operation,
* determined by [RampStateManager][com.tangem.domain.exchange.RampStateManager]
*/
data class SwapCurrencyStatus(
val userWallet: UserWallet,
val status: CryptoCurrencyStatus,
val account: Account,
val isAvailableForSwap: Boolean = true,
) {
val currency: CryptoCurrency
get() = status.currency
val userWalletId: UserWalletId
get() = userWallet.walletId
}

View file

@ -9,7 +9,8 @@ import java.math.BigDecimal
* List of saved swap transactions
*/
data class SwapTransactionListModel(
val userWalletId: String,
val fromUserWalletId: String,
val toUserWalletId: String,
val fromCryptoCurrencyId: String,
val toCryptoCurrencyId: String,
val fromCryptoCurrency: CryptoCurrency,

View file

@ -16,6 +16,21 @@ import java.math.BigDecimal
@Suppress("LongParameterList")
interface SwapRepositoryV2 {
/**
* Returns express swap pairs for a specific primary and secondary currency.
*
* @param primarySwapCurrencyStatus primary currency status participating in the swap
* @param secondarySwapCurrencyStatus secondary currency status participating in the swap
* @param filterProviderTypes filters only specified provider types, if empty returns providers as is
* @param swapTxType swap tx type
*/
suspend fun getPairs(
primarySwapCurrencyStatus: SwapCurrencyStatus,
secondarySwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
): List<SwapPairModel>
/**
* Express swap pairs, both direct and reversed
*
@ -84,7 +99,7 @@ interface SwapRepositoryV2 {
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrency: CryptoCurrency,
amount: String,
amount: BigDecimal,
amountType: SwapAmountType,
toAddress: String,
toExtraId: String?,

View file

@ -18,7 +18,8 @@ interface SwapTransactionRepository {
/**
* Store new swap transaction
*
* @param userWalletId selected user wallet id
* @param fromUserWalletId wallet id swap from
* @param toUserWalletId wallet id swap to
* @param fromCryptoCurrency currency swap from
* @param toCryptoCurrency currency swap to
* @param fromAccount account swap from
@ -27,7 +28,8 @@ interface SwapTransactionRepository {
*/
@Suppress("LongParameterList")
suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,

View file

@ -12,6 +12,7 @@ import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDataModel
import java.math.BigDecimal
@Suppress("LongParameterList")
class GetSwapDataUseCase(
@ -22,7 +23,7 @@ class GetSwapDataUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
amount: String,
amount: BigDecimal,
amountType: SwapAmountType,
toCryptoCurrency: CryptoCurrency,
toAddress: String,

View file

@ -0,0 +1,35 @@
package com.tangem.domain.swap.usecase
import arrow.core.Either
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapTxType
/**
* Use case for retrieving swap pairs between a specific primary and secondary currency.
*
* Returns either a list of available swap pairs or a resolved swap error.
*
* @property swapRepositoryV2 repository providing swap pair data
* @property swapErrorResolver resolver that maps exceptions to domain swap errors
*/
class GetSwapPairUseCase(
private val swapRepositoryV2: SwapRepositoryV2,
private val swapErrorResolver: SwapErrorResolver,
) {
suspend operator fun invoke(
primarySwapCurrencyStatus: SwapCurrencyStatus,
secondarySwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
) = Either.catch {
swapRepositoryV2.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = filterProviderTypes,
swapTxType = swapTxType,
)
}.mapLeft(swapErrorResolver::resolve)
}

View file

@ -2,7 +2,6 @@ package com.tangem.domain.swap.usecase
import arrow.core.Either
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -19,7 +18,8 @@ class SwapTransactionSentUseCase(
) {
suspend operator fun invoke(
userWallet: UserWallet,
fromUserWallet: UserWallet,
toUserWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
fromAccount: Account?,
@ -33,7 +33,8 @@ class SwapTransactionSentUseCase(
) = Either.catch {
if (provider.type.shouldStoreSwapTransaction()) {
swapTransactionRepository.storeTransaction(
userWalletId = userWallet.walletId,
fromUserWalletId = fromUserWallet.walletId,
toUserWalletId = toUserWallet.walletId,
fromCryptoCurrency = fromCryptoCurrencyStatus.currency,
toCryptoCurrency = toCryptoCurrencyStatus.currency,
fromAccount = fromAccount,
@ -58,11 +59,11 @@ class SwapTransactionSentUseCase(
}
swapTransactionRepository.storeLastSwappedCryptoCurrencyId(
userWalletId = userWallet.walletId,
userWalletId = fromUserWallet.walletId,
cryptoCurrencyId = toCryptoCurrencyStatus.currency.id,
)
swapRepositoryV2.swapTransactionSent(
userWallet = userWallet,
userWallet = fromUserWallet,
fromCryptoCurrencyStatus = fromCryptoCurrencyStatus,
payInAddress = payInAddress,
txId = swapDataTransactionModel.txId,

View file

@ -56,7 +56,6 @@ dependencies {
/** Utils */
implementation(deps.jodatime)
implementation(deps.reKotlin)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")

View file

@ -6,6 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
sealed class TokenReceiveNewAnalyticsEvent(
event: String,
@ -36,7 +37,7 @@ sealed class TokenReceiveNewAnalyticsEvent(
BLOCKCHAIN to blockchainName,
SOURCE to tokenReceiveSource.name,
),
)
), AppsFlyerIncludedEvent
class ButtonCopyEns(
token: String,

View file

@ -0,0 +1,6 @@
package com.tangem.domain.tokens.model.warnings
sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() {
data object FundsFound : DynamicAddressesWarnings()
}

View file

@ -3,14 +3,11 @@ package com.tangem.domain.tokens.actions
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState.ActionState
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -65,20 +62,6 @@ internal class CommonActionsFactory(
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
}
val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom &&
cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote
) {
async {
getSwapUnavailabilityReason(
userWalletId = userWallet.walletId,
currencyStatus = cryptoCurrencyStatus,
requirementsDeferred = requirementsDeferred,
)
}
} else {
null
}
val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet)
actionAvailabilityBuilder {
@ -111,7 +94,6 @@ internal class CommonActionsFactory(
createSwapAction(
userWallet = userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
swapUnavailableReasonDeferred = swapUnavailabilityReason,
shouldShowSwapStories = shouldShowSwapStories,
).addByReason()
// endregion
@ -140,11 +122,9 @@ internal class CommonActionsFactory(
}
}
@Suppress("CanBeNonNullable")
private suspend fun createSwapAction(
private fun createSwapAction(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
swapUnavailableReasonDeferred: Deferred<ScenarioUnavailabilityReason>?,
shouldShowSwapStories: Boolean,
): ActionState {
val cryptoCurrency = cryptoCurrencyStatus.currency
@ -172,35 +152,11 @@ internal class CommonActionsFactory(
)
}
else -> {
val reason = requireNotNull(swapUnavailableReasonDeferred) {
"swapUnavailableReasonDeferred must not be null for available swap action"
}.await()
return ActionState.Swap(
unavailabilityReason = reason,
shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories,
ActionState.Swap(
unavailabilityReason = ScenarioUnavailabilityReason.None,
shouldShowBadge = shouldShowSwapStories,
)
}
}
}
private suspend fun getSwapUnavailabilityReason(
userWalletId: UserWalletId,
currencyStatus: CryptoCurrencyStatus,
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
): ScenarioUnavailabilityReason {
val swapUnavailabilityReason = rampStateManager
.availableForSwap(userWalletId = userWalletId, cryptoCurrency = currencyStatus.currency)
val shouldCheckAssetRequirements =
swapUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null
val yieldSupplyStatus = currencyStatus.value.yieldSupplyStatus
val isUnavailableByYieldSupply = yieldSupplyStatus?.isAllowedToSpend == false && yieldSupplyStatus.isActive
return when {
isUnavailableByYieldSupply -> ScenarioUnavailabilityReason.YieldSupplyApprovalRequired
shouldCheckAssetRequirements -> getReceiveScenario(requirementsDeferred.await())
else -> swapUnavailabilityReason
}
}
}

View file

@ -190,7 +190,7 @@ class WalletBalanceFetcher internal constructor(
private suspend fun fetchExpressAssets(userWallet: UserWallet, currencies: Set<CryptoCurrency>) {
val assetIds = currencies.mapTo(hashSetOf()) { currency ->
ExpressAsset.ID(
networkId = currency.network.backendId,
networkId = currency.network.rawId,
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
)
}

View file

@ -18,7 +18,6 @@ internal object MockNetworks {
name = "Network One",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,
@ -32,7 +31,6 @@ internal object MockNetworks {
name = "Network Two",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,
@ -46,7 +44,6 @@ internal object MockNetworks {
name = "Network Three",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,

View file

@ -1,25 +0,0 @@
package com.tangem.domain.tokensync.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.model.TokenSyncProgress
import kotlinx.coroutines.flow.Flow
interface TokenSyncRepository {
suspend fun runSync(userWalletId: UserWalletId)
suspend fun completeSync(userWalletId: UserWalletId)
suspend fun getPendingSyncWalletIds(): List<UserWalletId>
fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress>
fun acknowledgeCompletion(userWalletId: UserWalletId)
suspend fun clearPendingFlag(userWalletId: UserWalletId)
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
}

View file

@ -1,13 +0,0 @@
package com.tangem.domain.tokensync.usecase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.repository.TokenSyncRepository
class AcknowledgeTokenSyncCompletionUseCase(
private val tokenSyncRepository: TokenSyncRepository,
) {
operator fun invoke(userWalletId: UserWalletId) {
tokenSyncRepository.acknowledgeCompletion(userWalletId)
}
}

View file

@ -1,15 +0,0 @@
package com.tangem.domain.tokensync.usecase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.model.TokenSyncProgress
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import kotlinx.coroutines.flow.Flow
class ObserveTokenSyncUseCase(
private val tokenSyncRepository: TokenSyncRepository,
) {
operator fun invoke(userWalletId: UserWalletId): Flow<TokenSyncProgress> {
return tokenSyncRepository.observeSyncProgress(userWalletId)
}
}

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