Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-24 12:21:14 +04:00
parent e381fafeb8
commit 7cb4b57fe9
21 changed files with 556 additions and 230 deletions

View file

@ -3,10 +3,13 @@ package com.tangem.tap.di.domain
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.account.usecase.*
import com.tangem.feature.referral.data.ExternalReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -43,9 +46,15 @@ internal object AccountDomainModule {
@Provides
@Singleton
fun provideArchiveCryptoPortfolioUseCase(
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
accountsCRUDRepository: AccountsCRUDRepository,
referralRepository: ExternalReferralRepository,
): ArchiveCryptoPortfolioUseCase {
return ArchiveCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository)
return ArchiveCryptoPortfolioUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
crudRepository = accountsCRUDRepository,
referralRepository = referralRepository,
)
}
@Provides

View file

@ -1,5 +1,6 @@
package com.tangem.domain.account.models
import arrow.core.Either
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
@ -40,4 +41,14 @@ data class AccountStatusList(
fun flattenCurrencies(): List<CryptoCurrencyStatus> = accountStatuses
.map { accountStatus -> accountStatus.flattenCurrencies() }
.flatten()
fun toAccountList(): Either<AccountList.Error, AccountList> {
return AccountList(
userWalletId = userWalletId,
accounts = accountStatuses.map(AccountStatus::account),
totalAccounts = totalAccounts,
sortType = sortType,
groupType = groupType,
)
}
}

View file

@ -1,152 +0,0 @@
package com.tangem.domain.account.usecase
import arrow.core.None
import arrow.core.left
import arrow.core.right
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase.Error
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.mock.MockAccounts.createAccount
import io.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)
class ArchiveCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository)
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository)
}
@Test
fun `invoke should archive existing crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(derivationIndex = 1)
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccountListSync(userWalletId)
crudRepository.saveAccounts(updatedAccountList)
}
}
@Test
fun `invoke should return error if getAccounts returns None`() = runTest {
// Arrange
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex.Main,
)
coEvery { crudRepository.getAccountListSync(userWalletId) } returns None
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if getAccounts throws exception`() = runTest {
// Arrange
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex.Main,
)
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if account not found`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
)
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.CriticalTechError.AccountNotFound(accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(derivationIndex = 1)
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!
val exception = IllegalStateException("Save failed")
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccountListSync(userWalletId)
crudRepository.saveAccounts(updatedAccountList)
}
}
private companion object {
val userWalletId = UserWalletId("011")
}
}

View file

@ -23,6 +23,7 @@ dependencies {
api(projects.domain.quotes)
api(projects.domain.models)
api(projects.domain.networks)
api(projects.domain.referral)
api(projects.domain.staking)
api(projects.domain.tokens)
api(projects.domain.wallets)
@ -42,7 +43,7 @@ dependencies {
// end
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(tangemDeps.blockchain)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
}

View file

@ -91,7 +91,7 @@ internal object AccountStatusUseCaseModule {
@Provides
@Singleton
fun provideManageCryptoCurrenciesUseCase(
singleAccountListSupplier: SingleAccountListSupplier,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
accountsCRUDRepository: AccountsCRUDRepository,
currenciesRepository: CurrenciesRepository,
derivationsRepository: DerivationsRepository,
@ -103,7 +103,7 @@ internal object AccountStatusUseCaseModule {
dispatchers: CoroutineDispatcherProvider,
): ManageCryptoCurrenciesUseCase {
return ManageCryptoCurrenciesUseCase(
singleAccountListSupplier = singleAccountListSupplier,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
accountsCRUDRepository = accountsCRUDRepository,
currenciesRepository = currenciesRepository,
derivationsRepository = derivationsRepository,

View file

@ -1,31 +1,48 @@
package com.tangem.domain.account.usecase
package com.tangem.domain.account.status.usecase
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.*
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.referral.ReferralRepository
/**
* Use case for archiving a crypto portfolio.
* This class provides functionality to archive a specific account within a user's crypto portfolio.
* It ensures that the account exists and meets the necessary requirements before performing the operation.
*
* @property singleAccountStatusListSupplier supplier to get the list of account statuses
* @property crudRepository repository for performing CRUD operations on accounts
* @property referralRepository repository for handling referral status checks
*
[REDACTED_AUTHOR]
*/
class ArchiveCryptoPortfolioUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val crudRepository: AccountsCRUDRepository,
private val referralRepository: ReferralRepository,
) {
/** Archives the specified account by its [accountId] */
suspend operator fun invoke(accountId: AccountId): Either<Error, Unit> = either {
val accountList = getAccountList(userWalletId = accountId.userWalletId)
val accountStatusList = getAccountStatusList(userWalletId = accountId.userWalletId)
val accountList = accountStatusList.toAccountList().getOrElse {
raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it))
}
ensure(accountList.accounts.any { it.accountId == accountId }) {
Error.CriticalTechError.AccountNotFound(accountId = accountId)
}
checkReferralStatus(accountStatusList = accountStatusList, accountId = accountId)
val archivingAccount = accountList.accounts
.firstOrNull { it.accountId == accountId }
@ -38,16 +55,48 @@ class ArchiveCryptoPortfolioUseCase(
saveAccounts(updatedAccounts)
}
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
private suspend fun Raise<Error>.checkReferralStatus(accountStatusList: AccountStatusList, accountId: AccountId) {
val referralStatus = catch(
block = { referralRepository.getReferralStatus(userWalletId = accountStatusList.userWalletId.stringValue) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }
val referralToken = referralStatus.token
val address = referralStatus.address
if (!referralStatus.isActive || referralToken == null || address == null) return
val account = accountStatusList.accountStatuses
.asSequence()
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.firstOrNull { it.accountId == accountId }
ensureNotNull(account) {
Error.CriticalTechError.AccountNotFound(accountId = accountId)
}
val statuses = account.flattenCurrencies()
val hasNotReferralToken = statuses.none { status ->
val currency = status.currency
currency.network.backendId == referralToken.networkId &&
(currency as? CryptoCurrency.Token)?.contractAddress == referralToken.contractAddress &&
status.value.networkAddress?.availableAddresses?.any { it.value == address } == true
}
ensure(hasNotReferralToken) { Error.ActiveReferralStatus }
}
private suspend fun Raise<Error>.getAccountStatusList(userWalletId: UserWalletId): AccountStatusList {
return singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(userWalletId = userWalletId),
)
?: raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId))
}
private suspend fun Raise<Error>.saveAccounts(accountList: AccountList) {
catch(
arrow.core.raise.catch(
block = { crudRepository.saveAccounts(accountList) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
@ -58,9 +107,14 @@ class ArchiveCryptoPortfolioUseCase(
*/
sealed interface Error {
val tag: String
get() = this.javaClass.simpleName
data object ActiveReferralStatus : Error
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : Error {
override fun toString(): String = "$this: Data operation failed: ${cause.message ?: "Unknown error"}"
override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}"
}
/**

View file

@ -82,6 +82,7 @@ class GetAccountCurrencyByAddressUseCase(
for (id in userWalletIds) {
val networkStatus = multiNetworkStatusSupplier.getSyncOrNull(
params = MultiNetworkStatusProducer.Params(userWalletId = id),
timeMillis = 1000L,
)
?.firstOrNull { it.getAddress() == address }

View file

@ -3,16 +3,18 @@ package com.tangem.domain.account.status.usecase
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.utils.NetworksCleaner
@ -27,7 +29,7 @@ import timber.log.Timber
/**
* Use case for saving crypto currencies to a specific account.
*
* @property singleAccountListSupplier Supplier to get account details.
* @property singleAccountStatusListSupplier Supplier for fetching the status of a single account.
* @property accountsCRUDRepository Repository for performing CRUD operations on accounts.
* @property currenciesRepository Repository for managing currencies.
* @property derivationsRepository Repository for deriving public keys.
@ -43,7 +45,7 @@ import timber.log.Timber
*/
@Suppress("LongParameterList")
class ManageCryptoCurrenciesUseCase(
private val singleAccountListSupplier: SingleAccountListSupplier,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val currenciesRepository: CurrenciesRepository,
private val derivationsRepository: DerivationsRepository,
@ -76,12 +78,13 @@ class ManageCryptoCurrenciesUseCase(
val userWalletId = accountId.userWalletId
withContext(NonCancellable) {
val account = getAccount(accountId = accountId)
val accountStatus = getAccountStatus(accountId = accountId)
val modifiedCurrencyList = account.cryptoCurrencies.modify(add = add, remove = remove)
val modifiedCurrencyList = accountStatus.tokenList.flattenCurrencies()
.modify(add = add, remove = remove)
saveAccount(
account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()),
account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()),
)
val isDerivingFailed = derivePublicKeys(
@ -117,10 +120,10 @@ class ManageCryptoCurrenciesUseCase(
val userWalletId = accountId.userWalletId
withContext(NonCancellable) {
val account = getAccount(accountId = accountId)
val accountStatus = getAccountStatus(accountId = accountId)
val foundToken = account.cryptoCurrencies
.filterIsInstance<CryptoCurrency.Token>()
val foundToken = accountStatus.tokenList.flattenCurrencies()
.mapNotNull { it.currency as? CryptoCurrency.Token }
.firstOrNull {
it.network.backendId == networkId &&
!it.isCustom &&
@ -131,9 +134,10 @@ class ManageCryptoCurrenciesUseCase(
val tokenToAdd = findToken(userWalletId, contractAddress, networkId)
val modifiedCurrencyList = account.cryptoCurrencies.modify(add = listOf(tokenToAdd))
val modifiedCurrencyList = accountStatus.tokenList.flattenCurrencies()
.modify(add = listOf(tokenToAdd))
saveAccount(account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()))
saveAccount(account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()))
parallelUpdatingScope.launch {
cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd))
@ -144,26 +148,31 @@ class ManageCryptoCurrenciesUseCase(
}
}
private suspend fun Raise<Throwable>.getAccount(accountId: AccountId): Account.CryptoPortfolio {
val accountList = singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = accountId.userWalletId),
private suspend fun Raise<Throwable>.getAccountStatus(accountId: AccountId): AccountStatus.CryptoPortfolio {
val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(
params = SingleAccountStatusListProducer.Params(userWalletId = accountId.userWalletId),
) ?: raise(IllegalStateException("No accounts for wallet ${accountId.userWalletId}"))
return accountList.accounts.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
return accountStatusList.accountStatuses
.firstOrNull { it.accountId == accountId } as? AccountStatus.CryptoPortfolio
?: raise(IllegalStateException("No account with id $accountId"))
}
private fun Set<CryptoCurrency>.modify(
private fun List<CryptoCurrencyStatus>.modify(
add: List<CryptoCurrency>,
remove: List<CryptoCurrency> = emptyList(),
): ModifiedCurrencyList {
val mutableCurrencies = this.toMutableList()
val mutableCurrencies = this.map(CryptoCurrencyStatus::currency).toMutableList()
val added = mutableListOf<CryptoCurrency>()
val removed = mutableListOf<CryptoCurrency>()
val existingCurrenciesById = mutableCurrencies.associateBy(::TempID)
val existingCurrenciesById = this.associateBy(::TempID)
add.groupByNetwork { !existingCurrenciesById.containsKey(it) }
add.groupByNetwork { tempID ->
val found = existingCurrenciesById[tempID] ?: return@groupByNetwork true
found.value is CryptoCurrencyStatus.MissedDerivation
}
.forEach { (network, currenciesById) ->
val coinTempId = TempID(network)
@ -204,20 +213,6 @@ class ManageCryptoCurrenciesUseCase(
return ModifiedCurrencyList(added = added, removed = removed, total = mutableCurrencies)
}
private suspend fun Raise<Throwable>.saveAccount(account: Account.CryptoPortfolio) {
catch(
block = { accountsCRUDRepository.saveAccount(account) },
catch = ::raise,
)
}
private suspend fun derivePublicKeys(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): Either<Throwable, Unit> = Either.catch {
derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies)
}
private fun List<CryptoCurrency>.groupByNetwork(
valuePredicate: (TempID) -> Boolean,
): LinkedHashMap<Network, MutableMap<TempID, CryptoCurrency>> {
@ -230,13 +225,27 @@ class ManageCryptoCurrenciesUseCase(
val id = TempID(currency)
if (valuePredicate(id)) {
mutableMap.put(id, currency)
mutableMap[id] = currency
}
}
return destination
}
private suspend fun Raise<Throwable>.saveAccount(account: Account.CryptoPortfolio) {
catch(
block = { accountsCRUDRepository.saveAccount(account) },
catch = ::raise,
)
}
private suspend fun derivePublicKeys(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): Either<Throwable, Unit> = Either.catch {
derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies)
}
private suspend fun Raise<Throwable>.findToken(
userWalletId: UserWalletId,
networkId: String,
@ -307,6 +316,12 @@ class ManageCryptoCurrenciesUseCase(
derivationPath = currency.network.derivationPath,
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
)
constructor(status: CryptoCurrencyStatus) : this(
networkId = status.currency.network.backendId,
derivationPath = status.currency.network.derivationPath,
contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress,
)
}
private data class ModifiedCurrencyList(

View file

@ -0,0 +1,307 @@
package com.tangem.domain.account.status.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase.Error
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.referral.ReferralRepository
import com.tangem.domain.referral.ReferralStatus
import com.tangem.test.mock.MockAccounts.createAccountList
import io.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)
class ArchiveCryptoPortfolioUseCaseTest {
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val referralRepository: ReferralRepository = mockk(relaxUnitFun = true)
private val useCase = ArchiveCryptoPortfolioUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
crudRepository = crudRepository,
referralRepository = referralRepository,
)
@BeforeEach
fun resetMocks() {
clearMocks(singleAccountStatusListSupplier, referralRepository, crudRepository)
}
@Test
fun `invoke should archive existing crypto portfolio account`() = runTest {
// Arrange
val accountList = createAccountList(activeAccounts = 2)
val archivingAccount = accountList.accounts.last()
val updatedAccountList = (accountList - archivingAccount).getOrNull()!!
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus()
val referralStatus = ReferralStatus(isActive = false, token = null, address = null)
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
// Act
val actual = useCase(accountId = archivingAccount.accountId)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
singleAccountStatusListSupplier.getSyncOrNull(params)
referralRepository.getReferralStatus(userWalletId.stringValue)
crudRepository.saveAccounts(updatedAccountList)
}
}
@Test
fun `invoke should archive if referral status is active but token is null`() = runTest {
// Arrange
val accountList = createAccountList(activeAccounts = 2)
val archivingAccount = accountList.accounts.last()
val updatedAccountList = (accountList - archivingAccount).getOrNull()!!
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus()
val referralStatus = ReferralStatus(isActive = true, token = null, address = null)
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
// Act
val actual = useCase(accountId = archivingAccount.accountId)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
singleAccountStatusListSupplier.getSyncOrNull(params)
referralRepository.getReferralStatus(userWalletId.stringValue)
crudRepository.saveAccounts(updatedAccountList)
}
}
@Test
fun `invoke should archive if referral status is active but token is absent`() = runTest {
// Arrange
val accountList = createAccountList(activeAccounts = 2)
val archivingAccount = accountList.accounts.last()
val updatedAccountList = (accountList - archivingAccount).getOrNull()!!
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus()
val token = ReferralStatus.Token(
networkId = "ethereum",
contractAddress = "0x1",
)
val referralStatus = ReferralStatus(isActive = true, token = token, address = "0xABC")
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
// Act
val actual = useCase(accountId = archivingAccount.accountId)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
singleAccountStatusListSupplier.getSyncOrNull(params)
referralRepository.getReferralStatus(userWalletId.stringValue)
crudRepository.saveAccounts(updatedAccountList)
}
}
@Test
fun `invoke returns error if account contains referral token `() = runTest {
// Arrange
val token = ReferralStatus.Token(
networkId = "ethereum",
contractAddress = "0x1",
)
val defaultAddress = "0xABC"
val cryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.network.backendId } returns token.networkId
every { this@mockk.contractAddress } returns token.contractAddress!!
}
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = defaultAddress,
type = NetworkAddress.Address.Type.Primary,
),
)
val cryptoCurrencyStatus = mockk<CryptoCurrencyStatus> {
every { this@mockk.currency } returns cryptoCurrency
every { this@mockk.value.networkAddress } returns networkAddress
}
val accountList = createAccountList(activeAccounts = 2)
val archivingAccount = accountList.accounts.last()
val archivingAccountStatus = AccountStatus.CryptoPortfolio(
account = archivingAccount as Account.CryptoPortfolio,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Failed,
sortedBy = TokensSortType.NONE,
currencies = listOf(cryptoCurrencyStatus),
),
priceChangeLce = Unit.lceError(),
)
val accountListStatus = with(accountList.toStatus()) {
copy(
accountStatuses = accountStatuses.filter { it.accountId != archivingAccount.accountId } +
archivingAccountStatus,
)
}
(accountList - archivingAccount).getOrNull()!!
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountListStatus
val referralStatus = ReferralStatus(isActive = true, token = token, address = defaultAddress)
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
// Act
val actual = useCase(accountId = archivingAccount.accountId)
// Assert
val expected = Error.ActiveReferralStatus.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
singleAccountStatusListSupplier.getSyncOrNull(params)
referralRepository.getReferralStatus(userWalletId.stringValue)
}
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if supplier returns null`() = runTest {
// Arrange
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
)
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns null
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { singleAccountStatusListSupplier.getSyncOrNull(params) }
coVerify(inverse = true) {
referralRepository.getReferralStatus(any())
crudRepository.saveAccounts(any())
}
}
@Test
fun `invoke should return error if account not found`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex.Companion(1).getOrNull()!!,
)
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus()
val referralStatus = ReferralStatus(isActive = false, token = null, address = null)
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.CriticalTechError.AccountNotFound(accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { singleAccountStatusListSupplier.getSyncOrNull(params) }
coVerify(inverse = true) {
referralRepository.getReferralStatus(any())
crudRepository.saveAccounts(any())
}
}
@Test
fun `invoke should return error if saveAccounts throws exception`() = runTest {
val accountList = createAccountList(activeAccounts = 2)
val archivingAccount = accountList.accounts.last()
val updatedAccountList = (accountList - archivingAccount).getOrNull()!!
val params = SingleAccountStatusListProducer.Params(userWalletId)
coEvery { singleAccountStatusListSupplier.getSyncOrNull(params) } returns accountList.toStatus()
val referralStatus = ReferralStatus(isActive = false, token = null, address = null)
coEvery { referralRepository.getReferralStatus(userWalletId.stringValue) } returns referralStatus
val exception = IllegalStateException("Save failed")
coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception
// Act
val actual = useCase(accountId = archivingAccount.accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
singleAccountStatusListSupplier.getSyncOrNull(params)
referralRepository.getReferralStatus(userWalletId.stringValue)
crudRepository.saveAccounts(updatedAccountList)
}
}
private fun AccountList.toStatus(): AccountStatusList {
return AccountStatusList(
userWalletId = Companion.userWalletId,
accountStatuses = accounts.map {
AccountStatus.CryptoPortfolio(
account = it as Account.CryptoPortfolio,
tokenList = TokenList.Empty,
priceChangeLce = Unit.lceError(),
)
},
totalAccounts = totalAccounts,
totalFiatBalance = TotalFiatBalance.Loading,
sortType = sortType,
groupType = groupType,
)
}
private companion object {
val userWalletId = UserWalletId("011")
}
}

View file

@ -113,7 +113,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
// Arrange
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns null
// Act
@ -124,7 +124,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
}
}
@ -133,7 +133,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
// Arrange
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns emptySet()
// Act
@ -144,7 +144,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
}
}
@ -158,7 +158,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns setOf(networkStatus)
// Act
@ -169,7 +169,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
}
}
@ -185,7 +185,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
@ -201,7 +201,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
@ -222,7 +222,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
@ -238,7 +238,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
@ -257,7 +257,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
@ -274,7 +274,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
coVerifySequence {
accountsCRUDRepository.getUserWalletsSync()
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)

View file

@ -2,6 +2,7 @@ package com.tangem.domain.core.flow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
/**
* [Flow] supplier
@ -16,8 +17,14 @@ interface FlowSupplier<Params : Any, Data : Any> {
/** Supply [Flow] by [params] */
operator fun invoke(params: Params): Flow<Data>
/** Get first [Data] by [params] or null if [Flow] is empty */
suspend fun getSyncOrNull(params: Params): Data? {
return invoke(params).firstOrNull()
/** Synchronously get first value or null from [Flow] within [timeMillis] */
suspend fun getSyncOrNull(params: Params, timeMillis: Long? = null): Data? {
val block = suspend { invoke(params).firstOrNull() }
return if (timeMillis == null) {
block()
} else {
withTimeoutOrNull(timeMillis) { block() }
}
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,9 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
dependencies {
api(deps.kotlin.serialization)
}

View file

@ -0,0 +1,28 @@
package com.tangem.domain.referral
import kotlinx.serialization.Serializable
/**
* Repository for handling referral-related operations.
*
[REDACTED_AUTHOR]
*/
interface ReferralRepository {
/** Retrieves the referral status for a given [userWalletId] */
suspend fun getReferralStatus(userWalletId: String): ReferralStatus
}
@Serializable
data class ReferralStatus(
val isActive: Boolean,
val token: Token?,
val address: String?,
) {
@Serializable
data class Token(
val networkId: String,
val contractAddress: String?,
)
}

View file

@ -13,8 +13,8 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.isMultiCurrency
@ -104,13 +104,14 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) {
// todo account referral case
val titleRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.common_something_went_wrong
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus,
-> R.string.account_could_not_archive_referral_program_title
}
val messageRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
@ -118,6 +119,8 @@ internal class AccountDetailsModel @Inject constructor(
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.account_could_not_archive
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus,
-> R.string.account_could_not_archive_referral_program_message
}
val dialogMessage = DialogMessage(

View file

@ -25,6 +25,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.referral)
implementation(projects.features.referral.domain)
/** Libs */

View file

@ -1,7 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:ReferralConverter.kt$ExpectedAwardsConverter${ ExpectedAward( paymentDate = it.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(), amount = "${it.amount} ${it.currency}", ) }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -70,10 +70,10 @@ private class ExpectedAwardsConverter : Converter<ReferralResponse.ExpectedAward
override fun convert(value: ReferralResponse.ExpectedAwards): ExpectedAwards {
return ExpectedAwards(
numberOfWallets = value.numberOfWallets,
expectedAwards = value.list.map {
expectedAwards = value.list.map { awardItem ->
ExpectedAward(
paymentDate = it.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(),
amount = "${it.amount} ${it.currency}",
paymentDate = awardItem.paymentDate.toDateTimeAtStartOfDay().millis.toDateFormat(),
amount = "${awardItem.amount} ${awardItem.currency}",
)
},
)

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.referral.ReferralStatus
import com.tangem.feature.referral.converters.ReferralConverter
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.feature.referral.domain.models.ReferralData
@ -21,13 +22,15 @@ import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
typealias ExternalReferralRepository = com.tangem.domain.referral.ReferralRepository
internal class ReferralRepositoryImpl @Inject constructor(
private val referralApi: TangemTechApi,
private val referralConverter: ReferralConverter,
private val coroutineDispatcher: CoroutineDispatcherProvider,
private val userWalletsStore: UserWalletsStore,
excludedBlockchains: ExcludedBlockchains,
) : ReferralRepository {
) : ReferralRepository, ExternalReferralRepository {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
@ -47,6 +50,21 @@ internal class ReferralRepositoryImpl @Inject constructor(
}
}
override suspend fun getReferralStatus(userWalletId: String): ReferralStatus {
val data = getReferralData(userWalletId)
return ReferralStatus(
isActive = data is ReferralData.ParticipantData,
token = data.tokens.firstOrNull()?.let { tokenData ->
ReferralStatus.Token(
networkId = tokenData.networkId,
contractAddress = tokenData.contractAddress,
)
},
address = (data as? ReferralData.ParticipantData)?.referral?.address,
)
}
override suspend fun isReferralParticipant(userWalletId: UserWalletId): Boolean {
val storedReferralData = referralStatus[userWalletId.stringValue] ?: getReferralData(userWalletId.stringValue)
return storedReferralData is ReferralData.ParticipantData

View file

@ -4,6 +4,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.feature.referral.converters.ReferralConverter
import com.tangem.feature.referral.data.ExternalReferralRepository
import com.tangem.feature.referral.data.ReferralRepositoryImpl
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -34,4 +35,22 @@ class ReferralRepositoryModule {
excludedBlockchains = excludedBlockchains,
)
}
@Provides
@Singleton
fun provideExternalReferralRepository(
tangemTechApi: TangemTechApi,
referralConverter: ReferralConverter,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
userWalletsStore: UserWalletsStore,
excludedBlockchains: ExcludedBlockchains,
): ExternalReferralRepository {
return ReferralRepositoryImpl(
referralApi = tangemTechApi,
referralConverter = referralConverter,
coroutineDispatcher = coroutineDispatcherProvider,
userWalletsStore = userWalletsStore,
excludedBlockchains = excludedBlockchains,
)
}
}

View file

@ -360,6 +360,7 @@ include(":domain:notifications")
include(":domain:notifications:models")
include(":domain:express")
include(":domain:express:models")
include(":domain:referral")
include(":domain:swap")
include(":domain:swap:models")
include(":domain:wallet-manager")