From 75cdf4011c47c9ca3591810302de9e5dbe9fe543 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Sep 2025 12:59:18 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/common/test/utils/TruthExt.kt | 14 + domain/account/status/build.gradle.kts | 17 +- .../status/model/AccountCryptoCurrency.kt | 20 ++ .../GetAccountCurrencyByAddressUseCase.kt | 136 ++++++++ .../GetAccountCurrencyByAddressUseCaseTest.kt | 299 ++++++++++++++++++ 5 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt diff --git a/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt index 67cd37711b..3349cef006 100644 --- a/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt +++ b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt @@ -1,6 +1,8 @@ package com.tangem.common.test.utils import arrow.core.Either +import arrow.core.None +import arrow.core.Option import com.google.common.truth.Truth fun assertEither(actual: Either, expected: Either) { @@ -29,4 +31,16 @@ fun assertEitherLeft(actual: Either, expected: Throwable) { Truth.assertThat(it::class.java).isEqualTo(expected::class.java) Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message) } +} + +fun assertNone(actual: Option) { + Truth.assertThat(actual).isEqualTo(None) +} + +fun assertSome(actual: Option, expected: B) { + actual + .onNone { error("Actual is None") } + .onSome { + Truth.assertThat(it).isEqualTo(expected) + } } \ No newline at end of file diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 5c127cfeac..1bc12d67f3 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) alias(deps.plugins.hilt.android) + alias(deps.plugins.kotlin.serialization) id("configuration") } @@ -15,18 +16,20 @@ tasks.withType().configureEach { } dependencies { - implementation(projects.domain.account) - implementation(projects.domain.core) + api(projects.domain.account) + api(projects.domain.core) api(projects.domain.common) - implementation(projects.domain.quotes) - implementation(projects.domain.models) - implementation(projects.domain.networks) - implementation(projects.domain.staking) - implementation(projects.domain.tokens) + api(projects.domain.quotes) + api(projects.domain.models) + api(projects.domain.networks) + api(projects.domain.staking) + api(projects.domain.tokens) implementation(projects.libs.crypto) implementation(deps.kotlin.datetime) + implementation(deps.kotlin.serialization) + implementation(deps.timber) // region DI implementation(deps.hilt.android) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt new file mode 100644 index 0000000000..e4dabbc8cc --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/model/AccountCryptoCurrency.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.account.status.model + +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.serialization.Serializable + +/** + * Combines an [Account] with its corresponding [CryptoCurrency]. + * + * This data class is useful for representing the relationship between an account and the cryptocurrency it holds. + * +[REDACTED_AUTHOR] + */ +@Serializable +data class AccountCryptoCurrency(val account: Account.CryptoPortfolio, val cryptoCurrency: CryptoCurrency) { + + init { + require(account.cryptoCurrencies.contains(cryptoCurrency)) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt new file mode 100644 index 0000000000..f038816b91 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt @@ -0,0 +1,136 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.NonEmptyList +import arrow.core.None +import arrow.core.Option +import arrow.core.raise.OptionRaise +import arrow.core.raise.RaiseDSL +import arrow.core.raise.option +import arrow.core.toNonEmptyListOrNull +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.model.AccountCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.getAddress +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.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import timber.log.Timber +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +private typealias WalletIdWithNetworkId = Pair + +/** + * Use case to retrieve an [AccountCryptoCurrency] based on a provided address. + * + * @property userWalletsListRepository Repository to access user wallets. + * @property multiNetworkStatusSupplier Supplier to get network status for multiple networks. + * @property singleAccountListSupplier Supplier to get account lists for a single wallet. + * +[REDACTED_AUTHOR] + */ +class GetAccountCurrencyByAddressUseCase( + private val userWalletsListRepository: UserWalletsListRepository, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val singleAccountListSupplier: SingleAccountListSupplier, +) { + + /** + * Invokes the use case to get an [AccountCryptoCurrency] for the given address. + * + * @param address The address to look up. + * @return An [Option] containing the [AccountCryptoCurrency] if found, or [None] if not found or if any validation + * fails. + */ + suspend operator fun invoke(address: String): Option = option { + validate(address) + + val userWalletIds = getUserWalletIds() + + val (walletId, networkId) = getWalletIdWithNetworkId(userWalletIds, address) + + val accountList = getAccountList(userWalletId = walletId) + + getAccountCryptoCurrency(accountList, networkId) + } + + private fun OptionRaise.validate(address: String) { + ensureNotNull(address.takeIf { it.isNotEmpty() }) { "Address is empty" } + } + + private fun OptionRaise.getUserWalletIds(): NonEmptyList { + val userWalletIds = userWalletsListRepository.userWallets.value.orEmpty() + .filter(UserWallet::isMultiCurrency) + .map(UserWallet::walletId) + .toNonEmptyListOrNull() + + return ensureNotNull(userWalletIds) { "No multi-currency wallets found" } + } + + private suspend fun OptionRaise.getWalletIdWithNetworkId( + userWalletIds: NonEmptyList, + address: String, + ): WalletIdWithNetworkId { + var pair: WalletIdWithNetworkId? = null + + for (id in userWalletIds) { + val networkStatus = multiNetworkStatusSupplier.getSyncOrNull( + params = MultiNetworkStatusProducer.Params(userWalletId = id), + ) + ?.firstOrNull { it.getAddress() == address } + + if (networkStatus != null) { + pair = id to networkStatus.network.id + break + } + } + + return ensureNotNull(value = pair) { "No network status found for address: $address" } + } + + private suspend fun OptionRaise.getAccountList(userWalletId: UserWalletId): AccountList { + val accountList = singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + + return ensureNotNull(accountList) { "No account list found for walletId: $userWalletId" } + } + + private fun OptionRaise.getAccountCryptoCurrency( + accountList: AccountList, + networkId: Network.ID, + ): AccountCryptoCurrency { + val result = accountList.accounts.asSequence() + .filterIsInstance() + .mapNotNull { account -> + val currency = account.cryptoCurrencies.firstOrNull { it.network.id == networkId } + ?: return@mapNotNull null + + AccountCryptoCurrency(account = account, cryptoCurrency = currency) + } + .firstOrNull() + + return ensureNotNull(result) { + "No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}" + } + } + + @OptIn(ExperimentalContracts::class) + @RaiseDSL + inline fun OptionRaise.ensureNotNull(value: B?, message: () -> String): B { + contract { + returns() implies (value != null) + } + + return value ?: run { + Timber.d(message()) + raise(None) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt new file mode 100644 index 0000000000..829ce7b3ec --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt @@ -0,0 +1,299 @@ +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.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.model.AccountCryptoCurrency +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.network.NetworkStatus +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.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetAccountCurrencyByAddressUseCaseTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + + private val useCase = GetAccountCurrencyByAddressUseCase( + userWalletsListRepository = userWalletsListRepository, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + singleAccountListSupplier = singleAccountListSupplier, + ) + + @AfterEach + fun tearDown() { + clearMocks(userWalletsListRepository, multiNetworkStatusSupplier, singleAccountListSupplier) + } + + @Test + fun `returns None if address is empty`() = runTest { + // Arrange + val address = "" + + // Act + val actual = useCase(address) + + // Assert + assertNone(actual) + } + + @Test + fun `returns None if userWalletIds is null`() = runTest { + // Arrange + every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + } + } + + @Test + fun `returns None if userWalletIds is empty list`() = runTest { + // Arrange + every { userWalletsListRepository.userWallets } returns MutableStateFlow(emptyList()) + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + } + } + + @Test + fun `returns None if userWalletIds contain only single wallet`() = runTest { + // Arrange + val singleWallet = mockk { + every { isMultiCurrency } returns false + } + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(singleWallet)) + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + } + } + + @Test + fun `returns None if multiNetworkStatusSupplier returns null`() = runTest { + // Arrange + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns null + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } + } + + @Test + fun `returns None if multiNetworkStatusSupplier returns empty list`() = runTest { + // Arrange + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns emptySet() + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } + } + + @Test + fun `returns None if network status not found`() = runTest { + // Arrange + val networkStatus = NetworkStatus( + network = mockk(), + value = NetworkStatus.Unreachable(address = null), + ) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns setOf(networkStatus) + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } + } + + @Test + fun `returns None if singleAccountListSupplier returns null`() = runTest { + // Arrange + val networkStatus = NetworkStatus( + network = mockk { + every { id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) + }, + value = NetworkStatus.Unreachable(address = validNetworkAddress), + ) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns setOf(networkStatus) + coEvery { + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } returns null + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } + } + + @Test + fun `returns None if crypto currencies is empty list`() = runTest { + // Arrange + val networkId = Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) + val networkStatus = NetworkStatus( + network = mockk { + every { id } returns networkId + }, + value = NetworkStatus.Unreachable(address = validNetworkAddress), + ) + val accountList = AccountList.empty(multiUserWallet) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns setOf(networkStatus) + coEvery { + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } returns accountList + + // Act + val actual = useCase(validAddress) + + // Assert + assertNone(actual) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } + } + + @Test + fun `returns Some if all data is valid`() = runTest { + // Arrange + val currency = MockCryptoCurrencyFactory().ethereum + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = validNetworkAddress), + ) + val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency)) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) + coEvery { + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + } returns setOf(networkStatus) + coEvery { + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } returns accountList + + // Act + val actual = useCase(validAddress) + + // Assert + val expected = AccountCryptoCurrency(account = accountList.mainAccount, cryptoCurrency = currency) + assertSome(actual, expected) + + coVerifySequence { + userWalletsListRepository.userWallets + multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId)) + singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + ) + } + } + + private companion object Companion { + const val validAddress = "0x1234567890abcdef" + val validNetworkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = validAddress, + type = NetworkAddress.Address.Type.Primary, + ), + ) + + val userWalletId = UserWalletId("011") + val multiUserWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + } +} \ No newline at end of file