Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-08 17:35:41 +04:00
parent aefaf636e8
commit 9ff657252a
8 changed files with 272 additions and 13 deletions

View file

@ -25,12 +25,15 @@ dependencies {
api(projects.domain.staking)
api(projects.domain.tokens)
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,5 +1,6 @@
package com.tangem.domain.account.status.di
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.supplier.SingleAccountListSupplier
@ -31,7 +32,9 @@ internal object AccountStatusUseCaseModule {
@Provides
@Singleton
fun provideGetAccountCurrencyStatusUseCase(): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase()
fun provideGetAccountCurrencyStatusUseCase(
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier)
}
}

View file

@ -2,24 +2,110 @@ 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
/**
* 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 operator fun invoke(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Option<AccountCryptoCurrencyStatus> {
return invoke(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 operator fun invoke(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
network: Network?,
): Option<AccountCryptoCurrencyStatus> {
val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(
params = SingleAccountStatusListProducer.Params(userWalletId),
) ?: return none()
return accountStatusList.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()
.toOption()
}
/**
* 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,157 @@
package com.tangem.domain.account.status.usecase
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.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.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class 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)
}
@Test
fun `invoke returns None when supplier returns null`() = runTest {
// Arrange
coEvery { supplier.getSyncOrNull(supplierParams) } returns null
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invoke 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(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invoke 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(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 `invoke 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(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
assertSome(actual, expected)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.lib.crypto.derivation
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.isUTXO
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.network.Network
/**
* Utility class to recognize the account node in a derivation path based on the blockchain type.
@ -21,12 +22,21 @@ class AccountNodeRecognizer(blockchain: Blockchain) {
NON_UTXO_BLOCKCHAIN_NODE_INDEX
}
/** Recognizes the account node value from the given [derivationPath] */
fun recognize(derivationPath: Network.DerivationPath): Long? {
val derivationPathValue = derivationPath.value ?: return null
return recognize(derivationPathValue = derivationPathValue)
}
/** Recognizes the account node value from the given derivation path string [derivationPathValue] */
fun recognize(derivationPathValue: String): Long? {
if (derivationPathValue.isBlank()) return null
return runCatching {
recognize(derivationPath = DerivationPath(rawPath = derivationPathValue))
}
.getOrNull()
val cardSdkDerivationPath = DerivationPath(rawPath = derivationPathValue)
recognize(derivationPath = cardSdkDerivationPath)
}.getOrNull()
}
/** Recognizes the account node value from the given [derivationPath] */