Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-13 13:02:12 +03:00
commit 3946e4ebca
359 changed files with 8459 additions and 3324 deletions

View file

@ -8,14 +8,14 @@ import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.serialization.Serializable
/**
* Represents a list of accounts associated with a user wallet
*
* @property userWallet the user wallet associated with the account list
* @property userWalletId the user wallet id associated with the account list
* @property accounts a set of accounts belonging to the user wallet
* @property totalAccounts the total number of accounts
*
@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class AccountList private constructor(
val userWallet: UserWallet,
val userWalletId: UserWalletId,
val accounts: Set<Account>,
val totalAccounts: Int,
val sortType: TokensSortType,
@ -51,7 +51,7 @@ data class AccountList private constructor(
val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId }
return invoke(
userWallet = this.userWallet,
userWalletId = this.userWalletId,
accounts = accounts,
totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0,
sortType = this.sortType,
@ -73,7 +73,7 @@ data class AccountList private constructor(
}
return invoke(
userWallet = this.userWallet,
userWalletId = this.userWalletId,
accounts = accounts,
totalAccounts = this.totalAccounts - if (isExistingAccount) 1 else 0,
sortType = this.sortType,
@ -134,12 +134,12 @@ data class AccountList private constructor(
* Factory method to create an `AccountList` instance.
* Validates the input to ensure the accounts list is not empty and contains exactly one main account.
*
* @param userWallet the user wallet associated with the account list
* @param userWalletId the user wallet id associated with the account list
* @param accounts a set of accounts belonging to the user wallet
* @param totalAccounts the total number of accounts
*/
operator fun invoke(
userWallet: UserWallet,
userWalletId: UserWalletId,
accounts: Set<Account>,
totalAccounts: Int,
sortType: TokensSortType = TokensSortType.NONE,
@ -169,7 +169,7 @@ data class AccountList private constructor(
}
AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = accounts,
totalAccounts = totalAccounts,
sortType = sortType,
@ -180,19 +180,19 @@ data class AccountList private constructor(
/**
* Factory method to create an empty [AccountList] with a main crypto portfolio account
*
* @param userWallet the user wallet associated with the account list
* @param userWalletId the user wallet id associated with the account list
*/
fun empty(
userWallet: UserWallet,
userWalletId: UserWalletId,
cryptoCurrencies: Set<CryptoCurrency> = emptySet(),
sortType: TokensSortType = TokensSortType.NONE,
groupType: TokensGroupType = TokensGroupType.NONE,
): AccountList {
return AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(
Account.CryptoPortfolio.createMainAccount(
userWalletId = userWallet.walletId,
userWalletId = userWalletId,
cryptoCurrencies = cryptoCurrencies,
),
),

View file

@ -3,13 +3,13 @@ package com.tangem.domain.account.models
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
/**
* Represents a list of account statuses associated with a user wallet
*
* @property userWallet the user wallet to which the account statuses belong
* @property userWalletId the user wallet id to which the account statuses belong
* @property accountStatuses a set of account statuses associated with the user wallet
* @property totalAccounts the total number of accounts (including archived ones)
* @property totalFiatBalance the total fiat balance across all accounts
@ -18,7 +18,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class AccountStatusList(
val userWallet: UserWallet,
val userWalletId: UserWalletId,
val accountStatuses: Set<AccountStatus>,
val totalAccounts: Int,
val totalFiatBalance: TotalFiatBalance,

View file

@ -68,6 +68,13 @@ interface AccountsCRUDRepository {
*/
suspend fun saveAccounts(accountList: AccountList)
/**
* Save account
*
* @param account account to be saved
*/
suspend fun saveAccount(account: Account.CryptoPortfolio)
/**
* Retrieves the total count of accounts associated with a specific user wallet including archived accounts
*

View file

@ -5,9 +5,11 @@ import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
@ -16,11 +18,13 @@ import com.tangem.domain.models.wallet.UserWalletId
* Use case for recovering a crypto portfolio account from archived accounts
*
* @property crudRepository repository for performing CRUD operations on accounts
* @property mainAccountTokensMigration handles the migration of tokens from the main account to the recovered account
*
[REDACTED_AUTHOR]
*/
class RecoverCryptoPortfolioUseCase(
private val crudRepository: AccountsCRUDRepository,
private val mainAccountTokensMigration: MainAccountTokensMigration,
) {
/**
@ -30,15 +34,25 @@ class RecoverCryptoPortfolioUseCase(
*/
suspend operator fun invoke(accountId: AccountId): Either<Error, Account.CryptoPortfolio> = either {
val accountList = getAccountList(userWalletId = accountId.userWalletId)
ensure(accountList.canAddMoreAccounts) {
raise(Error.AccountListRequirementsNotMet(cause = AccountList.Error.ExceedsMaxAccountsCount))
}
val archivedAccount = getArchivedAccount(accountId = accountId)
val recoveredAccount = archivedAccount.recover()
val updatedAccountList = (accountList + recoveredAccount)
.getOrElse { raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it)) }
.getOrElse { raise(Error.AccountListRequirementsNotMet(cause = it)) }
saveAccounts(updatedAccountList)
mainAccountTokensMigration.migrate(
userWalletId = accountId.userWalletId,
derivationIndex = recoveredAccount.derivationIndex,
)
recoveredAccount
}
@ -47,7 +61,9 @@ class RecoverCryptoPortfolioUseCase(
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }
.getOrElse {
raise(Error.DataOperationFailed(message = "Account list not found for wallet $userWalletId"))
}
}
private suspend fun Raise<Error>.getArchivedAccount(accountId: AccountId): ArchivedAccount {
@ -56,7 +72,7 @@ class RecoverCryptoPortfolioUseCase(
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse {
raise(Error.CriticalTechError.AccountNotFound(accountId = accountId))
raise(Error.DataOperationFailed(message = "Account not found: $accountId"))
}
}
@ -66,7 +82,6 @@ class RecoverCryptoPortfolioUseCase(
accountName = this.name,
icon = this.icon,
derivationIndex = this.derivationIndex,
// TODO: [REDACTED_JIRA]
cryptoCurrencies = emptySet(),
)
}
@ -87,37 +102,18 @@ class RecoverCryptoPortfolioUseCase(
get() = this::class.simpleName ?: "RecoverCryptoPortfolioUseCase.Error"
/**
* Critical technical errors that can occur during the recovery operation
* Error indicating that the account list requirements were not met.
*
* @property cause the underlying cause of the error
*/
sealed interface CriticalTechError : Error {
/**
*
* @property userWalletId the unique identifier of the user wallet
*/
data class AccountsNotCreated(val userWalletId: UserWalletId) : CriticalTechError {
override fun toString(): String = "$tag: Accounts for $userWalletId are not created"
}
/** Error indicating that the account with [accountId] was not found */
data class AccountNotFound(val accountId: AccountId) : CriticalTechError {
override fun toString(): String = "$tag: Account with ID $accountId not found"
}
/**
* Error indicating that the account list requirements were not met.
*
* @property cause the underlying cause of the error
*/
data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error {
override fun toString(): String = "$tag: Account list requirements not met: $cause"
}
data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error {
override fun toString(): String = "$tag: Account list requirements not met: $cause"
}
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : Error {
override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}"
constructor(message: String) : this(cause = IllegalStateException(message))
}
}
}

View file

@ -8,11 +8,7 @@ import com.tangem.domain.account.utils.createAccounts
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -31,7 +27,7 @@ class AccountListTest {
val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
val accountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
)
@ -49,13 +45,13 @@ class AccountListTest {
fun canAddMoreAccounts() {
// Arrange
val accountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 2),
totalAccounts = 2,
).getOrNull()!!
val fullAccountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!
@ -67,16 +63,13 @@ class AccountListTest {
@Test
fun empty() {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true)
// Act
val actual = AccountList.empty(userWallet)
val actual = AccountList.empty(userWalletId)
// Assert
val expected = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
).getOrNull()!!
@ -87,19 +80,12 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Create {
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(userWallet)
}
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: CreateTestModel) {
// Act
val actual = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = model.accounts,
totalAccounts = model.accounts.size,
)
@ -131,13 +117,13 @@ class AccountListTest {
createAccounts(userWalletId = userWalletId, count = 1).let {
CreateTestModel(
accounts = it,
expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 1),
expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 1),
)
},
createAccounts(userWalletId = userWalletId, count = 20).let {
CreateTestModel(
accounts = it,
expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 20),
expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 20),
)
},
CreateTestModel(
@ -171,8 +157,6 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Plus {
private val userWallet = mockk<UserWallet>()
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: PlusTestModel) {
@ -191,13 +175,13 @@ class AccountListTest {
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toAdd = newAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, newAccount),
totalAccounts = 2,
),
@ -211,13 +195,13 @@ class AccountListTest {
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toAdd = newAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(newAccount),
totalAccounts = 1,
),
@ -226,7 +210,7 @@ class AccountListTest {
// endregion
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!,
@ -246,8 +230,6 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Minus {
private val userWallet = mockk<UserWallet>()
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: MinusTestModel) {
@ -266,13 +248,13 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, secondaryAccount),
totalAccounts = 2,
).getOrNull()!!,
toRemove = secondaryAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
),
@ -286,13 +268,13 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toRemove = notInList,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
),
@ -305,7 +287,7 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
@ -321,7 +303,7 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, secondaryAccount),
totalAccounts = 2,
).getOrNull()!!,

View file

@ -14,7 +14,6 @@ import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.account.utils.createAccounts
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -35,20 +34,16 @@ class AddCryptoPortfolioUseCaseTest {
mainAccountTokensMigration = mainAccountTokensMigration,
)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration)
}
@Test
fun `invoke should add new crypto portfolio account to existing list`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
coEvery {
@ -152,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return error if account list requirements not met`() = runTest {
// Arrange
val accountList = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!
@ -228,7 +223,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
val exception = IllegalStateException("Test error")
@ -266,7 +261,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return new account if migrate returns error`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
val exception = Exception("Migration error")

View file

@ -11,7 +11,6 @@ import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -24,19 +23,17 @@ class ArchiveCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should archive existing crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = (AccountList.empty(userWallet) + account).getOrNull()!!
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!
@ -103,7 +100,7 @@ class ArchiveCryptoPortfolioUseCaseTest {
@Test
fun `invoke should return error if account not found`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
@ -126,7 +123,7 @@ class ArchiveCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = (AccountList.empty(userWallet) + account).getOrNull()!!
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!

View file

@ -8,11 +8,11 @@ import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -27,20 +27,22 @@ import org.junit.jupiter.api.TestInstance
class RecoverCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = RecoverCryptoPortfolioUseCase(crudRepository)
private val userWallet = mockk<UserWallet>()
private val mainAccountTokensMigration: MainAccountTokensMigration = mockk()
private val useCase = RecoverCryptoPortfolioUseCase(
crudRepository = crudRepository,
mainAccountTokensMigration = mainAccountTokensMigration,
)
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should recover archived crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.accountName,
@ -54,6 +56,7 @@ class RecoverCryptoPortfolioUseCaseTest {
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption()
coEvery { mainAccountTokensMigration.migrate(userWalletId, account.derivationIndex) } returns Unit.right()
// Act
val actual = useCase(account.accountId)
@ -62,7 +65,7 @@ class RecoverCryptoPortfolioUseCaseTest {
val expected = account.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
coVerifySequence {
crudRepository.getAccountListSync(userWalletId)
crudRepository.getArchivedAccountSync(account.accountId)
crudRepository.saveAccounts(updatedAccountList)
@ -80,13 +83,14 @@ class RecoverCryptoPortfolioUseCaseTest {
coEvery { crudRepository.getAccountListSync(userWalletId) } returns None
// Act
val actual = useCase(accountId)
val actual = useCase(accountId).leftOrNull() as Error.DataOperationFailed
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
val expected = IllegalStateException("Account list not found for wallet $userWalletId")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerifySequence { crudRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) {
crudRepository.getArchivedAccountSync(any())
crudRepository.saveAccounts(any())
@ -111,7 +115,7 @@ class RecoverCryptoPortfolioUseCaseTest {
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerifySequence { crudRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) {
crudRepository.getArchivedAccountSync(any())
crudRepository.saveAccounts(any())
@ -122,7 +126,7 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if getArchivedAccount throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
@ -135,7 +139,7 @@ class RecoverCryptoPortfolioUseCaseTest {
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
coVerifySequence {
crudRepository.getAccountListSync(userWalletId)
crudRepository.getArchivedAccountSync(account.accountId)
}
@ -146,19 +150,20 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if getArchivedAccount returns null`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None
// Act
val actual = useCase(account.accountId)
val actual = useCase(account.accountId).leftOrNull() as Error.DataOperationFailed
// Assert
val expected = Error.CriticalTechError.AccountNotFound(account.accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
val expected = IllegalStateException("Account not found: ${account.accountId}")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder {
coVerifySequence {
crudRepository.getAccountListSync(userWalletId)
crudRepository.getArchivedAccountSync(account.accountId)
}
@ -169,7 +174,7 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.accountName,
@ -193,7 +198,7 @@ class RecoverCryptoPortfolioUseCaseTest {
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
coVerifySequence {
crudRepository.getAccountListSync(userWalletId)
crudRepository.getArchivedAccountSync(account.accountId)
crudRepository.saveAccounts(updatedAccountList)

View file

@ -12,7 +12,6 @@ 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.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -29,19 +28,15 @@ class UpdateCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should update crypto portfolio account with new name`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -66,7 +61,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke should update crypto portfolio account with new icon`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount(
@ -94,7 +89,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke should update crypto portfolio account with new name and icon`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -123,7 +118,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if name and icon are null`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption()
@ -144,7 +139,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if getAccounts throws exception`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -192,7 +187,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if getAccounts does not contain accountId`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
@ -217,7 +212,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if saveAccounts throws exception`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!

View file

@ -24,13 +24,17 @@ dependencies {
api(projects.domain.networks)
api(projects.domain.staking)
api(projects.domain.tokens)
api(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(tangemDeps.blockchain)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)

View file

@ -1,10 +1,22 @@
package com.tangem.domain.account.status.di
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,7 +43,39 @@ internal object AccountStatusUseCaseModule {
@Provides
@Singleton
fun provideGetAccountCurrencyStatusUseCase(): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase()
fun provideGetAccountCurrencyStatusUseCase(
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier)
}
@Provides
@Singleton
fun provideSaveCryptoCurrenciesUseCase(
singleAccountListSupplier: SingleAccountListSupplier,
accountsCRUDRepository: AccountsCRUDRepository,
currenciesRepository: CurrenciesRepository,
derivationsRepository: DerivationsRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
stakingIdFactory: StakingIdFactory,
networksCleaner: NetworksCleaner,
stakingCleaner: StakingCleaner,
dispatchers: CoroutineDispatcherProvider,
): SaveCryptoCurrenciesUseCase {
return SaveCryptoCurrenciesUseCase(
singleAccountListSupplier = singleAccountListSupplier,
accountsCRUDRepository = accountsCRUDRepository,
currenciesRepository = currenciesRepository,
derivationsRepository = derivationsRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
stakingIdFactory = stakingIdFactory,
networksCleaner = networksCleaner,
stakingCleaner = stakingCleaner,
dispatchers = dispatchers,
)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
@ -39,6 +40,7 @@ import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor(
@Assisted private val params: SingleAccountStatusListProducer.Params,
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory,
private val dispatchers: CoroutineDispatcherProvider,
@ -58,8 +60,12 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
if (account.cryptoCurrencies.isEmpty()) {
createEmptyAccountStatusFlow(account)
} else {
val userWallet = userWalletsListRepository.userWalletsSync().first {
it.walletId == params.userWalletId
}
getAccountStatusFlow(
userWallet = accountList.userWallet,
userWallet = userWallet,
account = account,
groupType = accountList.groupType,
sortType = accountList.sortType,
@ -71,7 +77,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
val balances = accountStatuses.map { it.tokenList.totalFiatBalance }
AccountStatusList(
userWallet = accountList.userWallet,
userWalletId = accountList.userWalletId,
accountStatuses = accountStatuses.toSet(),
totalAccounts = accountList.totalAccounts,
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),

View file

@ -3,6 +3,8 @@ package com.tangem.domain.account.status.supplier
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.core.flow.FlowCachingSupplier
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Supplier that provides a single [AccountStatusList] for a specific user wallet.
@ -12,4 +14,10 @@ import com.tangem.domain.core.flow.FlowCachingSupplier
abstract class SingleAccountStatusListSupplier(
override val factory: SingleAccountStatusListProducer.Factory,
override val keyCreator: (SingleAccountStatusListProducer.Params) -> String,
) : FlowCachingSupplier<SingleAccountStatusListProducer, SingleAccountStatusListProducer.Params, AccountStatusList>()
) : FlowCachingSupplier<SingleAccountStatusListProducer, SingleAccountStatusListProducer.Params, AccountStatusList>() {
operator fun invoke(userWalletId: UserWalletId): Flow<AccountStatusList> {
val params = SingleAccountStatusListProducer.Params(userWalletId)
return this.invoke(params)
}
}

View file

@ -117,7 +117,7 @@ class GetAccountCurrencyByAddressUseCase(
.firstOrNull()
return ensureNotNull(result) {
"No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}"
"No account found for network: $networkId in walletId: ${accountList.userWalletId}"
}
}

View file

@ -2,24 +2,149 @@ package com.tangem.domain.account.status.usecase
import arrow.core.Option
import arrow.core.none
import arrow.core.toOption
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.Account
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.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
/**
* Use case to retrieve the status of a specific cryptocurrency associated with an account.
*
* @property singleAccountStatusListSupplier supplier to get the list of account statuses.
*
[REDACTED_AUTHOR]
*/
// TODO: Implement [REDACTED_JIRA]
class GetAccountCurrencyStatusUseCase {
class GetAccountCurrencyStatusUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
) {
/**
* Invokes the use case to get the [AccountCryptoCurrencyStatus] for the given [currencyId].
* Invokes the use case to get the status of a specific cryptocurrency for a given user wallet.
*
* @param currencyId The ID of the cryptocurrency to look up.
*
* @return An [Option] containing the [AccountCryptoCurrencyStatus] if found,
* or [arrow.core.None] if not found or if any validation fails.
* @param userWalletId the ID of the user wallet.
* @param currency the cryptocurrency for which the status is to be retrieved.
* @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None.
*/
suspend operator fun invoke(currencyId: CryptoCurrency.ID): Option<AccountCryptoCurrencyStatus> = none()
suspend fun invokeSync(userWalletId: UserWalletId, currency: CryptoCurrency): Option<AccountCryptoCurrencyStatus> {
return invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
}
/**
* Invokes the use case to get the status of a specific cryptocurrency by its ID for a given user wallet and network.
* If the [network] is null, it searches across all accounts for the cryptocurrency.
*
* @param userWalletId the ID of the user wallet.
* @param currencyId the ID of the cryptocurrency.
* @param network the network associated with the cryptocurrency, can be null.
* @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None.
*/
suspend fun invokeSync(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
network: Network?,
): Option<AccountCryptoCurrencyStatus> {
val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(
params = SingleAccountStatusListProducer.Params(userWalletId),
) ?: return none()
return accountStatusList
.toAccountCryptoCurrencyStatus(currencyId, network)
.toOption()
}
/**
* Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow].
*
* @param userWalletId The ID of the user wallet.
* @param currency The cryptocurrency for which the status is to be retrieved.
* @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found.
*/
operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Flow<AccountCryptoCurrencyStatus> {
return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
}
/**
* Retrieves the status of a specific cryptocurrency by its ID for a given user wallet and network as a [Flow].
*
* @param userWalletId The ID of the user wallet.
* @param currencyId The ID of the cryptocurrency.
* @param network The network associated with the cryptocurrency, can be null.
* @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found.
*/
operator fun invoke(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
network: Network?,
): Flow<AccountCryptoCurrencyStatus> {
return singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId),
)
.mapNotNull { accountStatusList ->
accountStatusList.toAccountCryptoCurrencyStatus(currencyId, network)
}
}
private fun AccountStatusList.toAccountCryptoCurrencyStatus(
currencyId: CryptoCurrency.ID,
network: Network?,
): AccountCryptoCurrencyStatus? {
return getExpectedAccountStatuses(network)
.asSequence()
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.mapNotNull { accountStatus ->
val status = accountStatus.flattenCurrencies().firstOrNull { it.currency.id == currencyId }
?: return@mapNotNull null
AccountCryptoCurrencyStatus(account = accountStatus.account, status = status)
}
.firstOrNull()
}
/**
* Retrieves the expected account statuses based on the provided [network].
* If the [network] is null, all account statuses are returned.
* If the network has a specific derivation index, it filters the accounts accordingly.
*
* @param network the network to filter accounts by, can be null.
* @return a set of [AccountStatus] that match the expected criteria.
*/
private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set<AccountStatus> {
val possibleAccountIndex = network?.getAccountIndexOrNull()
return when (possibleAccountIndex) {
// currency can be in any account
null -> accountStatuses
// currency only in the main account
DerivationIndex.Main.value -> setOf(mainAccount)
// currency only in the account with specific derivation index or in the main account
else -> {
val accountStatus = accountStatuses.firstOrNull {
val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false
cryptoPortfolio.derivationIndex.value == possibleAccountIndex
}
setOfNotNull(accountStatus, mainAccount)
}
}
}
private fun Network.getAccountIndexOrNull(): Int? {
val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null
val recognizer = AccountNodeRecognizer(blockchain)
return recognizer.recognize(derivationPath)?.toInt()
}
}

View file

@ -0,0 +1,269 @@
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.supplier.SingleAccountListSupplier
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
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.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import timber.log.Timber
/**
* Use case for saving crypto currencies to a specific account.
*
* @property singleAccountListSupplier Supplier to get account details.
* @property currenciesRepository Repository for managing currencies.
* @property derivationsRepository Repository for deriving public keys.
* @property multiNetworkStatusFetcher Fetcher for updating network statuses.
* @property multiQuoteStatusFetcher Fetcher for updating quote statuses.
* @property multiYieldBalanceFetcher Fetcher for updating yield balances.
* @property stakingIdFactory Factory for creating staking IDs.
* @property networksCleaner Cleaner for removing obsolete network data.
* @property stakingCleaner Cleaner for removing obsolete staking data.
* @property dispatchers Coroutine dispatchers for managing threading.
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
class SaveCryptoCurrenciesUseCase(
private val singleAccountListSupplier: SingleAccountListSupplier,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val currenciesRepository: CurrenciesRepository,
private val derivationsRepository: DerivationsRepository,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
private val stakingIdFactory: StakingIdFactory,
private val networksCleaner: NetworksCleaner,
private val stakingCleaner: StakingCleaner,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
accountId: AccountId,
add: List<CryptoCurrency>,
remove: List<CryptoCurrency>,
): Either<Throwable, Unit> = eitherOn(dispatchers.default) {
if (add.isEmpty() && remove.isEmpty()) {
Timber.d("No currencies to add or remove, skipping")
return@eitherOn
}
val userWalletId = accountId.userWalletId
withContext(NonCancellable) {
val account = getAccount(accountId = accountId)
val modifiedCurrencyList = account.cryptoCurrencies.modify(add = add, remove = remove)
saveAccount(
account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()),
)
derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added)
val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) +
clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed)
jobs.joinAll()
}
}
private suspend fun Raise<Throwable>.getAccount(accountId: AccountId): Account.CryptoPortfolio {
val accountList = singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = accountId.userWalletId),
) ?: raise(IllegalStateException("No accounts for wallet ${accountId.userWalletId}"))
return accountList.accounts.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: raise(IllegalStateException("No account with id $accountId"))
}
private fun Set<CryptoCurrency>.modify(
add: List<CryptoCurrency>,
remove: List<CryptoCurrency>,
): ModifiedCurrencyList {
val mutableCurrencies = this.toMutableList()
val added = mutableListOf<CryptoCurrency>()
val removed = mutableListOf<CryptoCurrency>()
val existingCurrenciesById = mutableCurrencies.associateBy(::TempID)
add.groupByNetwork { !existingCurrenciesById.containsKey(it) }
.forEach { (network, currenciesById) ->
val coinTempId = TempID(network)
if (!existingCurrenciesById.containsKey(coinTempId)) {
val coin = currenciesById[coinTempId]
if (coin != null) {
mutableCurrencies.add(coin)
added.add(coin)
currenciesById.remove(coinTempId)
} else {
val createdCoin = currenciesRepository.createCoinCurrency(network)
mutableCurrencies.add(createdCoin)
added.add(createdCoin)
}
}
mutableCurrencies.addAll(currenciesById.values)
added.addAll(currenciesById.values)
}
remove.groupByNetwork(valuePredicate = existingCurrenciesById::containsKey)
.forEach { (network, currenciesById) ->
val coinTempId = TempID(network)
if (currenciesById.containsKey(coinTempId)) {
val existingNetworkCurrenciesCount = mutableCurrencies.count { it.network == network }
if (existingNetworkCurrenciesCount != currenciesById.size) {
return@forEach
}
}
mutableCurrencies.removeAll(currenciesById.values)
removed.addAll(currenciesById.values)
}
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 Raise<Throwable>.derivePublicKeys(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
) {
catch(
block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) },
catch = ::raise,
)
}
private fun List<CryptoCurrency>.groupByNetwork(
valuePredicate: (TempID) -> Boolean,
): LinkedHashMap<Network, MutableMap<TempID, CryptoCurrency>> {
val destination = LinkedHashMap<Network, MutableMap<TempID, CryptoCurrency>>()
for (currency in this) {
val key = currency.network
val mutableMap = destination.getOrPut(key) { mutableMapOf() }
val id = TempID(currency)
if (valuePredicate(id)) {
mutableMap.put(id, currency)
}
}
return destination
}
private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List<CryptoCurrency>): List<Job> {
if (currencies.isEmpty()) return emptyList()
return coroutineScope {
listOf(
launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) },
launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) },
launch { refreshQuotes(currencies = currencies) },
)
}
}
private suspend fun refreshNetworks(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
multiNetworkStatusFetcher(
params = MultiNetworkStatusFetcher.Params(
userWalletId = userWalletId,
networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network),
),
)
currenciesRepository.syncTokens(userWalletId)
}
private suspend fun refreshYieldBalances(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
val stakingIds = currencies.mapNotNullTo(hashSetOf()) {
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull()
}
multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds),
)
}
private suspend fun refreshQuotes(currencies: List<CryptoCurrency>) {
multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(
currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
appCurrencyId = null,
),
)
}
private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List<CryptoCurrency>): List<Job> {
if (currencies.isEmpty()) return emptyList()
return coroutineScope {
listOf(
launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) },
launch { clearStaking(userWalletId = userWalletId, currencies = currencies) },
)
}
}
private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
val stakingIds = currencies.mapNotNullTo(hashSetOf()) {
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull()
}
stakingCleaner(userWalletId = userWalletId, stakingIds = stakingIds)
}
private data class TempID(
val networkId: String,
val derivationPath: Network.DerivationPath,
val contractAddress: String?,
) {
constructor(network: Network) : this(
networkId = network.backendId,
derivationPath = network.derivationPath,
contractAddress = null,
)
constructor(currency: CryptoCurrency) : this(
networkId = currency.network.backendId,
derivationPath = currency.network.derivationPath,
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
)
}
private data class ModifiedCurrencyList(
val added: List<CryptoCurrency>,
val removed: List<CryptoCurrency>,
val total: List<CryptoCurrency>,
)
}

View file

@ -0,0 +1,198 @@
package com.tangem.domain.account.status.producer
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultMultiAccountStatusListProducerTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val userWalletId1 = UserWalletId("001")
private val userWallet1 = mockk<UserWallet> {
every { walletId } returns userWalletId1
}
private val userWalletId2 = UserWalletId("002")
private val userWallet2 = mockk<UserWallet> {
every { walletId } returns userWalletId2
}
private val producer = DefaultMultiAccountStatusListProducer(
params = Unit,
userWalletsListRepository = userWalletsListRepository,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
dispatchers = dispatchers,
)
@AfterEach
fun tearDown() {
clearMocks(userWalletsListRepository, singleAccountStatusListSupplier)
}
@Test
fun `produce returns status lists for all user wallets`() = runTest {
// Arrange
val wallets = listOf(userWallet1, userWallet2)
val walletsFlow = MutableStateFlow(wallets)
every { userWalletsListRepository.userWallets } returns walletsFlow
val accountStatusList1 = mockk<AccountStatusList>()
val accountStatusList2 = mockk<AccountStatusList>()
every {
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
} returns flowOf(accountStatusList1)
every {
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
} returns flowOf(accountStatusList2)
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = listOf(accountStatusList1, accountStatusList2)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsListRepository.userWallets
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
}
}
@Test
fun `produce returns empty flow if userWallets is empty list`() = runTest {
// Arrange
val walletsFlow = MutableStateFlow<List<UserWallet>>(emptyList())
every { userWalletsListRepository.userWallets } returns walletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty()
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsListRepository.userWallets
}
}
@Test
fun `produce returns empty flow if userWallets is null`() = runTest {
// Arrange
val walletsFlow = MutableStateFlow<List<UserWallet>?>(null)
every { userWalletsListRepository.userWallets } returns walletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty()
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsListRepository.userWallets
}
}
@Test
fun `flow will updated if userWallets are updated`() = runTest {
// Arrange
val userWalletId3 = UserWalletId("003")
val userWallet3 = mockk<UserWallet> { every { walletId } returns userWalletId3 }
val walletsFlow = MutableStateFlow(listOf(userWallet1, userWallet2))
every { userWalletsListRepository.userWallets } returns walletsFlow
val accountStatusList1 = mockk<AccountStatusList>()
val accountStatusList2 = mockk<AccountStatusList>()
val accountStatusList3 = mockk<AccountStatusList>()
every {
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
} returns flowOf(accountStatusList1)
every {
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
} returns flowOf(accountStatusList2)
every {
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId3),
)
} returns flowOf(accountStatusList3)
// Act (first emission)
val actual1 = producer.produce().let(::getEmittedValues)
// Assert (first emission)
val expected1 = listOf(accountStatusList1, accountStatusList2)
Truth.assertThat(actual1).containsExactly(expected1)
// Act (second emission)
walletsFlow.value = listOf(userWallet1, userWallet2, userWallet3)
val actual2 = producer.produce().let(::getEmittedValues)
// Assert (second emission)
val expected2 = listOf(accountStatusList1, accountStatusList2, accountStatusList3)
Truth.assertThat(actual2).containsExactly(expected2)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsListRepository.userWallets
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId3),
)
userWalletsListRepository.userWallets
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId1),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId2),
)
singleAccountStatusListSupplier(
params = SingleAccountStatusListProducer.Params(userWalletId3),
)
}
}
}

View file

@ -0,0 +1,249 @@
package com.tangem.domain.account.status.producer
import arrow.core.nonEmptyListOf
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.quote.PriceChange
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultSingleAccountStatusListProducerTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk()
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
private val producer = DefaultSingleAccountStatusListProducer(
params = SingleAccountStatusListProducer.Params(userWalletId),
userWalletsListRepository = userWalletsListRepository,
singleAccountListSupplier = singleAccountListSupplier,
cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun tearDown() {
clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory)
}
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId = userWalletId)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
} returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(),
),
),
totalAccounts = 1,
totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
}
}
@Test
fun `flow will updated if balances are updated`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.BALANCE)
val accountListFlow = MutableStateFlow(value = accountList)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
} returns accountListFlow
// Act (first emission)
val actual1 = producer.produce().let(::getEmittedValues)
// Assert (first emission)
val expected = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(),
),
),
totalAccounts = 1,
totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL),
)
Truth.assertThat(actual1).containsExactly(expected)
// Act (second emission)
accountListFlow.value = updatedAccountList
val actual2 = producer.produce().let(::getEmittedValues)
// Assert (second emission)
val expected2 = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = updatedAccountList.mainAccount,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(),
),
),
totalAccounts = 1,
totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL),
)
Truth.assertThat(actual2).containsExactly(expected2)
coVerify(ordering = Ordering.SEQUENCE) {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
}
}
@Test
fun `flow is filtered the same balance`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId)
val accountListFlow = MutableStateFlow(value = accountList)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
} returns accountListFlow
val expected = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
tokenList = TokenList.Empty,
priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(),
),
),
totalAccounts = 1,
totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL),
)
// Act (first emission)
val actual1 = producer.produce().let(::getEmittedValues)
// Assert (first emission)
Truth.assertThat(actual1).containsExactly(expected)
// Act (second emission)
accountListFlow.value = accountList
val actual2 = producer.produce().let(::getEmittedValues)
// Assert (second emission)
Truth.assertThat(actual2).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
}
}
@Test
fun `flow is produced for account with non empty crypto currencies`() = runTest {
// Arrange
val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
val accountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
} returns flowOf(accountList)
val ethereumStatus = CryptoCurrencyStatus(
currency = cryptoCurrencyFactory.ethereum,
value = CryptoCurrencyStatus.Loading,
)
every {
cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.ethereum)
} returns flowOf(ethereumStatus)
val stellarStatus = CryptoCurrencyStatus(
currency = cryptoCurrencyFactory.stellar,
value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null),
)
every {
cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.stellar)
} returns flowOf(stellarStatus)
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = nonEmptyListOf(ethereumStatus, stellarStatus),
),
priceChangeLce = lceLoading(),
),
),
totalAccounts = 1,
totalFiatBalance = TotalFiatBalance.Loading,
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
}
}
}

View file

@ -218,7 +218,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
},
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(multiUserWallet)
val accountList = AccountList.empty(userWalletId)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
@ -253,7 +253,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
network = currency.network,
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency))
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {

View file

@ -0,0 +1,289 @@
package com.tangem.domain.account.status.usecase
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertNone
import com.tangem.common.test.utils.assertSome
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.utils.lceLoading
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.AccountStatus
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetAccountCurrencyStatusUseCaseTest {
private val supplier = mockk<SingleAccountStatusListSupplier>()
private val useCase = GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = supplier)
private val userWalletId = UserWalletId("011")
private val supplierParams = SingleAccountStatusListProducer.Params(userWalletId)
private val currency = MockCryptoCurrencyFactory().ethereum.let {
val derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/1")
it.copy(
network = it.network.copy(
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
derivationPath = derivationPath,
),
)
}
@BeforeEach
fun setUp() {
clearMocks(supplier)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class InvokeSync {
@Test
fun `invokeSync returns None when supplier returns null`() = runTest {
// Arrange
coEvery { supplier.getSyncOrNull(supplierParams) } returns null
// Act
val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invokeSync returns None when AccountList does not contain required currency id`() = runTest {
// Arrange
val accountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invokeSync returns Some if network is not null`() = runTest {
// Arrange
val mainAccountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!!
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk())
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase.invokeSync(
userWalletId = userWalletId,
currencyId = currency.id,
network = currency.network,
)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
assertSome(actual, expected)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invokeSync returns Some if network is null`() = runTest {
// Arrange
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
assertSome(actual, expected)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
}
@Suppress("UnusedFlow")
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Invoke {
@Test
fun `invoke returns empty flow when supplier returns empty flow`() = runTest {
// Arrange
coEvery { supplier(supplierParams) } returns emptyFlow()
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
.let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty()
coVerifyOrder { supplier(supplierParams) }
}
@Test
fun `invoke returns empty flow when AccountList does not contain required currency id`() = runTest {
// Arrange
val accountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
.let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty()
coVerifyOrder { supplier(supplierParams) }
}
@Test
fun `invoke returns data if network is not null`() = runTest {
// Arrange
val mainAccountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!!
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk())
}
coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
.let(::getEmittedValues)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
Truth.assertThat(actual).containsExactly(expected)
coVerifyOrder { supplier(supplierParams) }
}
@Test
fun `invoke returns data if network is null`() = runTest {
// Arrange
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
.let(::getEmittedValues)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
Truth.assertThat(actual).containsExactly(expected)
coVerifyOrder { supplier(supplierParams) }
}
}
}

View file

@ -0,0 +1,308 @@
package com.tangem.domain.account.status.utils
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.models.StatusSource
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.network.NetworkStatus
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.staking.single.SingleYieldBalanceSupplier
import io.mockk.*
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoCurrencyStatusesFlowFactoryTest {
private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk()
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val factory = CryptoCurrencyStatusesFlowFactory(
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleYieldBalanceSupplier = singleYieldBalanceSupplier,
stakingIdFactory = stakingIdFactory,
)
private val userWalletId = UserWalletId("011")
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(value = "0x1", type = NetworkAddress.Address.Type.Primary),
)
@AfterEach
fun tearDown() {
clearMocks(
singleNetworkStatusSupplier,
singleQuoteStatusSupplier,
singleYieldBalanceSupplier,
stakingIdFactory,
)
}
@Test
fun `if rawCurrencyId is null, there will be no subscription to the quote status`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum.copy(
id = cryptoCurrencyFactory.ethereum.id.copy(
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"),
),
)
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val stakingId = StakingID(integrationId = "id", address = networkAddress.defaultAddress.value)
coEvery {
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
} returns stakingId.right()
val yieldBalance = YieldBalance.Empty(stakingId = stakingId, source = StatusSource.ACTUAL)
val yieldBalanceFlow = flowOf(yieldBalance)
every {
singleYieldBalanceSupplier(
params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId),
)
} returns yieldBalanceFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
singleYieldBalanceSupplier(params = SingleYieldBalanceProducer.Params(userWalletId, stakingId))
}
}
@Test
fun `if userWallet is not multi-currency, there will be no subscription to the yield balance`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns false
}
val currency = cryptoCurrencyFactory.ethereum
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val quoteStatus = QuoteStatus(
rawCurrencyId = currency.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ONE,
),
)
val quoteStatusFlow = flowOf(quoteStatus)
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns quoteStatusFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = BigDecimal.ONE,
fiatRate = BigDecimal.ONE,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
@Test
fun `no subscription to the quote status and yield balance`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns false
}
val currency = cryptoCurrencyFactory.ethereum.copy(
id = cryptoCurrencyFactory.ethereum.id.copy(
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"),
),
)
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
}
}
@Test
fun `if stakingId is not supported, yield balance will be null`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = networkAddress),
)
val networkStatusFlow = flowOf(networkStatus)
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns networkStatusFlow
val quoteStatus = QuoteStatus(
rawCurrencyId = currency.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ONE,
),
)
val quoteStatusFlow = flowOf(quoteStatus)
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns quoteStatusFlow
coEvery {
stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value)
} returns StakingIdFactory.Error.UnsupportedCurrency.left()
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Unreachable(
priceChange = BigDecimal.ONE,
fiatRate = BigDecimal.ONE,
networkAddress = networkAddress,
),
)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
@Test
fun `all sources are empty`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val currency = cryptoCurrencyFactory.ethereum
every {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
} returns emptyFlow()
every {
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
} returns emptyFlow()
// Act
val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues)
// Assert
val expected = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network))
singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!))
}
}
}

View file

@ -212,6 +212,9 @@ data object Wallet2CardConfig : CardConfig {
Blockchain.HyperliquidTestnet -> EllipticCurve.Secp256k1
Blockchain.Quai -> EllipticCurve.Secp256k1
Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1
Blockchain.Linea -> EllipticCurve.Secp256k1
Blockchain.LineaTestnet -> EllipticCurve.Secp256k1
Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1
}
}
}

View file

@ -168,6 +168,9 @@ class Wallet2CardConfigTest {
Blockchain.HyperliquidTestnet to EllipticCurve.Secp256k1,
Blockchain.Quai to EllipticCurve.Secp256k1,
Blockchain.QuaiTestnet to EllipticCurve.Secp256k1,
Blockchain.Linea to EllipticCurve.Secp256k1,
Blockchain.LineaTestnet to EllipticCurve.Secp256k1,
Blockchain.ArbitrumNova to EllipticCurve.Secp256k1,
)
@Test

View file

@ -1,16 +0,0 @@
package com.tangem.domain.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import org.rekotlin.Action
sealed interface TokensAction : Action {
/** Single way to pass data to the screen */
sealed interface SetArgs : TokensAction {
object ManageAccess : SetArgs
object ReadAccess : SetArgs
}
}
data class TokenWithBlockchain(val token: Token, val blockchain: Blockchain)

View file

@ -1,5 +1,6 @@
package com.tangem.domain.markets
import kotlinx.serialization.Serializable
import org.joda.time.DateTime
import java.math.BigDecimal
@ -18,6 +19,8 @@ data class TokenMarketInfo(
val pricePerformance: PricePerformance?,
val exchangesAmount: Int?,
) {
@Serializable
data class Network(
val networkId: String,
val exchangeable: Boolean,

View file

@ -57,13 +57,17 @@ sealed interface Account {
val networksCount: Int
get() = cryptoCurrencies.map(CryptoCurrency::network).distinct().size
fun copy(accountName: AccountName = this.accountName, icon: CryptoPortfolioIcon = this.icon): CryptoPortfolio {
fun copy(
accountName: AccountName = this.accountName,
icon: CryptoPortfolioIcon = this.icon,
cryptoCurrencies: Set<CryptoCurrency> = this.cryptoCurrencies,
): CryptoPortfolio {
return CryptoPortfolio(
accountId = this.accountId,
accountName = accountName,
icon = icon,
derivationIndex = this.derivationIndex,
cryptoCurrencies = this.cryptoCurrencies,
cryptoCurrencies = cryptoCurrencies,
)
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.models.currency
fun CryptoCurrency.Token.yieldSupplyKey(): String {
return "${network.backendId}_$contractAddress"
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.networks.utils
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
/**
* Cleans up network-related data for a specific user wallet and a list of cryptocurrencies.
*
[REDACTED_AUTHOR]
*/
interface NetworksCleaner {
/**
* Cleans up network-related data for the given [userWalletId] and list of [currencies].
*
* @param userWalletId The ID of the user wallet for which to clean up data.
* @param currencies The list of cryptocurrencies whose associated network data should be cleaned.
*/
suspend operator fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.staking.utils
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
/**
* Cleans up staking-related data for a specific user wallet and a set of staking IDs.
*
[REDACTED_AUTHOR]
*/
interface StakingCleaner {
/**
* Cleans up staking-related data for the given [userWalletId] and set of [stakingIds].
*
* @param userWalletId The ID of the user wallet for which to clean up data.
* @param stakingIds The set of staking IDs whose associated data should be cleaned.
*/
suspend operator fun invoke(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -40,26 +40,7 @@ interface CurrenciesRepository {
* @param userWalletId The unique identifier of the user wallet.
* @param currencies The list of cryptocurrencies to be saved.
*/
suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
/**
* Add currencies to a specific user wallet.
*
* @param userWalletId The unique identifier of the user wallet.
* @param currencies The currencies which must be added.
* @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet
* ID provided.
*/
suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>): List<CryptoCurrency>
/**
* Saves the given list of cryptocurrencies for a specific multi-currency user wallet.
*
* @param userWalletId The unique identifier of the user wallet.
* @param currencies The list of cryptocurrencies to be saved.
*/
@Deprecated("Tech debt")
suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
/**
* Add currencies to a specific user wallet.
@ -256,6 +237,8 @@ interface CurrenciesRepository {
*/
suspend fun getFeePaidCurrency(userWalletId: UserWalletId, network: Network): FeePaidCurrency
fun createCoinCurrency(network: Network): CryptoCurrency.Coin
/**
* Creates token [cryptoCurrency] based on current token and [network] it`s will be added
*/

View file

@ -46,14 +46,7 @@ internal class MockCurrenciesRepository(
isTokensSortedByBalanceAfterSortingApply = isSortedByBalance
}
override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) = Unit
override suspend fun addCurrencies(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): List<CryptoCurrency> = emptyList()
override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) = Unit
override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) = Unit
override suspend fun addCurrenciesCache(
userWalletId: UserWalletId,
@ -152,6 +145,10 @@ internal class MockCurrenciesRepository(
return FeePaidCurrency.Coin
}
override fun createCoinCurrency(network: Network): CryptoCurrency.Coin {
error("not implemented")
}
override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
return cryptoCurrency
}

View file

@ -19,7 +19,7 @@ sealed class TangemPayTxHistoryItem {
val merchantName: String,
val enrichedMerchantCategory: String?,
val merchantCategory: String,
val status: String,
val status: Status,
val enrichedMerchantIconUrl: String?,
) : TangemPayTxHistoryItem()
@ -36,4 +36,12 @@ sealed class TangemPayTxHistoryItem {
override val amount: BigDecimal,
override val currency: Currency,
) : TangemPayTxHistoryItem()
enum class Status {
PENDING,
RESERVED,
COMPLETED,
DECLINED,
UNKNOWN,
}
}

View file

@ -2,8 +2,6 @@ package com.tangem.domain.pay.model
import java.math.BigDecimal
private const val APPROVED_KYC_STATUS = "APPROVED"
data class MainScreenCustomerInfo(
val info: CustomerInfo,
val orderStatus: OrderStatus,
@ -11,7 +9,7 @@ data class MainScreenCustomerInfo(
data class CustomerInfo(
val productInstance: ProductInstance?,
val kycStatus: String?,
val isKycApproved: Boolean,
val cardInfo: CardInfo?,
) {
@ -26,6 +24,4 @@ data class CustomerInfo(
val currencyCode: String,
val customerWalletAddress: String,
)
fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS
}

View file

@ -1,7 +1,7 @@
package com.tangem.domain.pay.model
enum class OrderStatus(val apiName: String) {
NOT_ISSUED(""),
UNKNOWN(""),
NEW("NEW"),
PROCESSING("PROCESSING"),
COMPLETED("COMPLETED"),

View file

@ -36,7 +36,8 @@ interface HotWalletPasswordRequester {
* @param hotWalletId The ID of the hot wallet to authenticate with.
* @param authMode Indicates whether the request is for authentication mode.
* In auth mode user can be deleted after failed attempts.
* @param hasBiometry Indicates whether to show biometric authentication option.
* @param hasBiometry Indicates whether to show biometric authentication option to the user.
* Will be ignored if the device does not support biometry at the moment of the request.
*/
data class AttemptRequest(
val hotWalletId: HotWalletId,

View file

@ -12,6 +12,8 @@ data class YieldMarketToken(
val chainId: Int,
val apy: SerializedBigDecimal,
val isActive: Boolean,
val maxFeeNative: String,
val maxFeeUSD: String,
val backendId: String? = null,
) {

View file

@ -1,19 +0,0 @@
package com.tangem.domain.yield.supply.models
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Domain model representing a token entry in the Yield Markets list.
*/
@Serializable
data class YieldMarketTokenStatus(
val tokenAddress: String,
val tokenSymbol: String,
val tokenName: String,
val chainId: Int,
val apy: SerializedBigDecimal,
val isActive: Boolean,
val maxFeeNative: String,
val maxFeeUSD: String,
)

View file

@ -1,38 +0,0 @@
package com.tangem.domain.yield.supply
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import kotlinx.coroutines.flow.Flow
interface YieldSupplyMarketRepository {
/**
* Get cached yield markets or null if nothing cached yet.
*/
suspend fun getCachedMarkets(): List<YieldMarketToken>?
/**
* Update markets by fetching from network and cache the result. Returns latest markets.
*/
@Throws
suspend fun updateMarkets(): List<YieldMarketToken>
/**
* Observe runtime markets updates.
*/
fun getMarketsFlow(): Flow<List<YieldMarketToken>>
/**
* Get yield token status by contract address.
*/
@Throws
suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus
/**
* Get yield token APY chart by contract address.
*/
@Throws
suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData
}

View file

@ -0,0 +1,59 @@
package com.tangem.domain.yield.supply
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import kotlinx.coroutines.flow.Flow
interface YieldSupplyRepository {
/**
* Get cached yield markets or null if nothing cached yet.
*/
suspend fun getCachedMarkets(): List<YieldMarketToken>?
/**
* Update markets by fetching from network and cache the result. Returns latest markets.
*/
@Throws
suspend fun updateMarkets(): List<YieldMarketToken>
/**
* Observe runtime markets updates.
*/
fun getMarketsFlow(): Flow<List<YieldMarketToken>>
/**
* Get yield token status by contract address from cache
*/
@Throws
suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken
/**
* Get yield token APY chart by contract address.
*/
@Throws
suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData
suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean
/**
* Activate yield protocol for the specified token.
*
* Returns whether the token is active after the operation completes.
* May throw on network/backend errors or if required chain id cannot be resolved.
*/
@Throws
suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean
/**
* Deactivate yield protocol for the specified token.
*
* Returns whether the token is active after the operation completes
* (expected to be false when deactivation succeeds). May throw on
* network/backend errors or if required chain id cannot be resolved.
*/
@Throws
suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplyActivateUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either<Throwable, Boolean> = Either.catch {
val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected")
yieldSupplyRepository.activateProtocol(token)
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.map
* - value: APY as string
*/
class YieldSupplyApyFlowUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
private val yieldSupplyRepository: YieldSupplyRepository,
) {
operator fun invoke(): Flow<Map<String, String>> {
return yieldSupplyMarketRepository.getMarketsFlow()
return yieldSupplyRepository.getMarketsFlow()
.map { yieldMarketTokenList ->
yieldMarketTokenList.filter { it.isActive }.associate { token ->
token.yieldSupplyKey to token.apy.toString()

View file

@ -1,7 +1,7 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import kotlin.collections.filter
/**
@ -12,11 +12,11 @@ import kotlin.collections.filter
* - value: APY as string
*/
class YieldSupplyApyUpdateUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(): Either<Throwable, Map<String, String>> = Either.catch {
yieldSupplyMarketRepository.updateMarkets()
yieldSupplyRepository.updateMarkets()
.filter { it.isActive }
.associate {
it.tokenAddress to it.apy.toString()

View file

@ -0,0 +1,15 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplyDeactivateUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either<Throwable, Boolean> = Either.catch {
val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected")
yieldSupplyRepository.deactivateProtocol(token)
}
}

View file

@ -1,14 +1,14 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplyGetApyUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(tokenAddress: String): Either<Throwable, String> = Either.catch {
val apys = yieldSupplyMarketRepository.getCachedMarkets() ?: yieldSupplyMarketRepository.updateMarkets()
val apys = yieldSupplyRepository.getCachedMarkets() ?: yieldSupplyRepository.updateMarkets()
apys.first { it.tokenAddress == tokenAddress }.apy.toString()
}
}

View file

@ -2,15 +2,15 @@ package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
class YieldSupplyGetChartUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(cryptoCurrency: CryptoCurrency.Token): Either<Throwable, YieldSupplyMarketChartData> =
Either.catch {
yieldSupplyMarketRepository.getTokenChart(cryptoCurrency)
yieldSupplyRepository.getTokenChart(cryptoCurrency)
}
}

View file

@ -2,14 +2,17 @@ package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
class YieldSupplyGetTokenStatusUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(token: CryptoCurrency.Token): Either<Throwable, YieldMarketTokenStatus> = Either.catch {
yieldSupplyMarketRepository.getTokenStatus(token)
suspend operator fun invoke(token: CryptoCurrency.Token): Either<Throwable, YieldMarketToken> = Either.catch {
val tokens = yieldSupplyRepository.getCachedMarkets().orEmpty()
val cachedStatus = tokens.firstOrNull { it.yieldSupplyKey == token.yieldSupplyKey() }
cachedStatus ?: error("YieldMarketToken not found")
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplyIsAvailableUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
return yieldSupplyRepository.isYieldSupplySupported(userWalletId, cryptoCurrency)
}
}