Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-17 18:00:29 +03:00
parent 678c1f4e88
commit 6ecbc20e26
6 changed files with 434 additions and 37 deletions

View file

@ -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")
}
}

View file

@ -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<YieldBalance> {
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
}
}

View file

@ -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<StakingID> {
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()
}
}

View file

@ -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<MultiYieldBalanceSupplier>()
private val stakingIdFactory = mockk<StakingIdFactory>()
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<Set<YieldBalance>>(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<Set<YieldBalance>>(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)
}
}

View file

@ -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<YieldBalance> {
data class Params(
val userWalletId: UserWalletId,
val currencyId: CryptoCurrency.ID,
val network: Network,
)
interface Factory : FlowProducer.Factory<Params, SingleYieldBalanceProducer>
}

View file

@ -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<SingleYieldBalanceProducer.Params, SingleYieldBalanceProducer>,
override val keyCreator: (SingleYieldBalanceProducer.Params) -> String,
) : FlowCachingSupplier<SingleYieldBalanceProducer, SingleYieldBalanceProducer.Params, YieldBalance>()