diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index 198ecb2e5a..3ccb9e47aa 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -24,6 +24,7 @@ import com.tangem.data.staking.converters.transaction.GasEstimateConverter import com.tangem.data.staking.converters.transaction.StakingTransactionConverter import com.tangem.data.staking.converters.transaction.StakingTransactionStatusConverter import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConverter +import com.tangem.data.staking.utils.StakingIdFactory.Companion.integrationIdMap import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.stakekit.StakeKitApi @@ -729,45 +730,13 @@ internal class DefaultStakingRepository( } @Suppress("unused") - private companion object { - const val YIELDS_STORE_KEY = "yields" + companion object { + private const val YIELDS_STORE_KEY = "yields" - const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking" - const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" - const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" - const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" - const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" - const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" - const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" - const val TRON_INTEGRATION_ID = "tron-trx-native-staking" - const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" - const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" - const val NEAR_INTEGRATION_ID = "near-near-native-staking" - const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" - const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking" + private const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + internal val YIELDS_WATITING_TIMEOUT = 15.seconds - val YIELDS_WATITING_TIMEOUT = 15.seconds - - val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") - - // uncomment items as implementation is ready - val integrationIdMap = mapOf( - Blockchain.TON.run { id + toCoinId() } to TON_INTEGRATION_ID, - Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID, - Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID, - Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, - Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, - // Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, - Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, - // Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID, - // Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID, - // Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID, - // Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID, - // Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID, - // Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID, - Blockchain.Cardano.run { id + toCoinId() } to CARDANO_INTEGRATION_ID, - ) + private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79") } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt new file mode 100644 index 0000000000..3327ef9790 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducer.kt @@ -0,0 +1,72 @@ +package com.tangem.data.staking.single + +import com.tangem.data.staking.store.YieldsBalancesStore.StakingID +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleYieldBalanceProducer] + * + * @property params params + * @property multiYieldBalanceSupplier multi yield balance supplier + * @property stakingIdFactory factory for creating [StakingID] + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor( + @Assisted private val params: SingleYieldBalanceProducer.Params, + private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier, + private val stakingIdFactory: StakingIdFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleYieldBalanceProducer { + + override val fallback: YieldBalance by lazy { + YieldBalance.Error( + integrationId = stakingIdFactory.createIntegrationId(currencyId = params.currencyId), + address = null, + ) + } + + override fun produce(): Flow { + return multiYieldBalanceSupplier( + params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId), + ) + .mapNotNull { + val currentStakingIds = stakingIdFactory.create( + userWalletId = params.userWalletId, + currencyId = params.currencyId, + network = params.network, + ) + + it.firstOrNull { balance -> + val integrationId = balance.integrationId + val address = balance.address + + if (integrationId == null || address == null) return@mapNotNull null + + val balanceStakingId = StakingID(integrationId = integrationId, address = address) + + currentStakingIds.contains(balanceStakingId) + } + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleYieldBalanceProducer.Factory { + override fun create(params: SingleYieldBalanceProducer.Params): DefaultSingleYieldBalanceProducer + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt new file mode 100644 index 0000000000..7e8362efb5 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/StakingIdFactory.kt @@ -0,0 +1,76 @@ +package com.tangem.data.staking.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toMigratedCoinId +import com.tangem.data.staking.store.YieldsBalancesStore.StakingID +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import javax.inject.Inject + +/** + * Factory of [StakingID] + * + * @property walletManagersFacade wallet manager facade + * +[REDACTED_AUTHOR] + */ +internal class StakingIdFactory @Inject constructor( + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): Set { + val addresses = walletManagersFacade.getAddresses(userWalletId = userWalletId, network = network) + + val integrationId = createIntegrationId(currencyId) ?: return emptySet() + + return addresses.mapTo(hashSetOf()) { address -> + StakingID(integrationId = integrationId, address = address.value) + } + } + + fun createIntegrationId(currencyId: CryptoCurrency.ID): String? { + val integrationKey = with(currencyId) { rawNetworkId.plus(rawCurrencyId) } + return integrationIdMap[integrationKey] + } + + @Suppress("UnusedPrivateMember") + companion object { + + private const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking" + private const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" + private const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" + private const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" + private const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" + private const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" + private const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" + private const val TRON_INTEGRATION_ID = "tron-trx-native-staking" + private const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" + private const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" + private const val NEAR_INTEGRATION_ID = "near-near-native-staking" + private const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" + private const val CARDANO_INTEGRATION_ID = "cardano-ada-native-staking" + + // uncomment items as implementation is ready + val integrationIdMap = mapOf( + Blockchain.TON.toDefaultKey() to TON_INTEGRATION_ID, + Blockchain.Solana.toDefaultKey() to SOLANA_INTEGRATION_ID, + Blockchain.Cosmos.toDefaultKey() to COSMOS_INTEGRATION_ID, + Blockchain.Tron.toDefaultKey() to TRON_INTEGRATION_ID, + Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + // Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + Blockchain.BSC.toDefaultKey() to BINANCE_INTEGRATION_ID, + // Blockchain.Polkadot.toDefaultKey() to POLKADOT_INTEGRATION_ID, + // Blockchain.Avalanche.toDefaultKey() to AVALANCHE_INTEGRATION_ID, + // Blockchain.Cronos.toDefaultKey() to CRONOS_INTEGRATION_ID, + // Blockchain.Kava.toDefaultKey() to KAVA_INTEGRATION_ID, + // Blockchain.Near.toDefaultKey() to NEAR_INTEGRATION_ID, + // Blockchain.Tezos.toDefaultKey() to TEZOS_INTEGRATION_ID, + Blockchain.Cardano.toDefaultKey() to CARDANO_INTEGRATION_ID, + ) + + private fun Blockchain.toDefaultKey(): String = id + toCoinId() + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt new file mode 100644 index 0000000000..3522d505db --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceProducerTest.kt @@ -0,0 +1,239 @@ +package com.tangem.data.staking.single + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.toDomain +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceProducerTest { + + private val params = SingleYieldBalanceProducer.Params( + userWalletId = UserWalletId(stringValue = "011"), + currencyId = ton.id, + network = ton.network, + ) + + private val multiNetworkStatusSupplier = mockk() + private val stakingIdFactory = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultSingleYieldBalanceProducer( + params = params, + stakingIdFactory = stakingIdFactory, + multiYieldBalanceSupplier = multiNetworkStatusSupplier, + dispatchers = dispatchers, + ) + + @Test + fun `test that flow is mapped for data from params`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val expected = flowOf( + setOf( + balance, + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ), + ) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(balance)) + } + + @Test + fun `test that flow is updated if yield balance is updated`() = runTest { + val expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + expected.emit(value = setOf(balance)) + + val values1 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(balance)) + + // second emit + val updatedStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = tonId.address) + expected.emit(value = setOf(updatedStatus)) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(listOf(balance, updatedStatus)) + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + expected.emit(value = setOf(balance)) + + val values1 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(balance)) + + // second emit + expected.emit(value = setOf(balance)) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balance)) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val innerFlow = MutableStateFlow(value = false) + val expected = flow { + if (innerFlow.value) { + emit(setOf(balance)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + every { stakingIdFactory.createIntegrationId(currencyId = params.currencyId) } returns tonId.integrationId + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values1 = getEmittedValues(flow = actual) + + coVerify(inverse = true) { stakingIdFactory.create(any(), any(), any()) } + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = null) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balance)) + } + + @Test + fun `test if flow doesn't contain network from params`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain() + + val expected = flowOf(setOf(balance)) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(0) + } + + @Test + fun `test if wallet manager facade returns empty set`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain() + + val expected = flowOf(setOf(balance)) + + val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns emptySet() + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = getEmittedValues(flow = actual) + + coVerify { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } + + Truth.assertThat(values.size).isEqualTo(0) + } + + private companion object { + + val mocks = MockCryptoCurrencyFactory() + + val ton = mocks.createCoin(Blockchain.TON) + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + val solanaId = YieldsBalancesStore.StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ) + + val stakingIds = setOf(tonId) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt new file mode 100644 index 0000000000..6951cf3a1b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceProducer.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of yield balance for selected wallet [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface SingleYieldBalanceProducer : FlowProducer { + + data class Params( + val userWalletId: UserWalletId, + val currencyId: CryptoCurrency.ID, + val network: Network, + ) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt new file mode 100644 index 0000000000..f7509dc980 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceSupplier.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance + +/** + * Supplier of yield balance for selected wallet [SingleYieldBalanceProducer.Params] + * + * @property factory factory for creating [SingleYieldBalanceProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleYieldBalanceSupplier( + override val factory: FlowProducer.Factory, + override val keyCreator: (SingleYieldBalanceProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file