Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-26 14:12:43 +03:00
commit c306b16a7b
1308 changed files with 41564 additions and 8590 deletions

View file

@ -69,11 +69,8 @@ dependencies {
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,264 @@
package com.tangem.data.staking
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.*
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* P2P staking repository implementation
*/
internal class DefaultP2PEthPoolRepository(
private val p2pApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter
private val accountInfoConverter = P2PEthPoolAccountConverter
private val rewardConverter = P2PEthPoolRewardConverter
private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter
private val errorConverter = P2PEthPoolErrorConverter
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = getVaults(network).getOrElse { error ->
Timber.e("Error fetching P2P vaults: $error")
emptyList()
}
p2pEthPoolVaultsStore.store(vaults)
}
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getVaults(network.value)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
result.vaults.map { vaultConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createDepositTransaction(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolDepositRequest(
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
)
val response = p2pApi.createDepositTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createUnstakeTransaction(
network: P2PEthPoolNetwork,
stakerPublicKey: String,
stakeTransactionHash: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolUnstakeRequest(
stakerPublicKey = stakerPublicKey,
stakeTransactionHash = stakeTransactionHash,
)
val response = p2pApi.createUnstakeTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
// Note: API returns only hex string for unstake, not full transaction structure
P2PEthPoolUnsignedTx(
serializeTx = result.unstakeTransactionHex,
to = "", // Will be parsed from hex by wallet
data = result.unstakeTransactionHex,
value = java.math.BigDecimal.ZERO,
nonce = 0,
chainId = network.chainId,
gasLimit = java.math.BigDecimal.ZERO,
maxFeePerGas = java.math.BigDecimal.ZERO,
maxPriorityFeePerGas = java.math.BigDecimal.ZERO,
)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createWithdrawTransaction(
network: P2PEthPoolNetwork,
stakerAddress: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
val response = p2pApi.createWithdrawTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun broadcastTransaction(
network: P2PEthPoolNetwork,
signedTransaction: String,
): Either<StakingError, P2PEthPoolBroadcastResult> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
val response = p2pApi.broadcastTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
broadcastResultConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun getAccountInfo(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount> = either {
withContext(dispatchers.io) {
val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
accountInfoConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun getRewards(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
period: Int?,
): Either<StakingError, List<P2PEthPoolReward>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getRewards(
network = network.value,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
period = period,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
result.rewards.map { rewardConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override fun getStakingAvailability(): Flow<StakingAvailability> {
return getVaultsFlow()
.distinctUntilChanged()
.map { vaults ->
if (vaults.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable
}
val vault = findPublicVault(vaults = vaults)
if (vault != null) {
StakingAvailability.Available(StakingOption.P2P(vault))
} else {
StakingAvailability.TemporaryUnavailable
}
}
}
override suspend fun getStakingAvailabilitySync(): StakingAvailability {
val vaults = getVaultsSync()
if (vaults.isEmpty()) {
return StakingAvailability.TemporaryUnavailable
}
val vault = findPublicVault(vaults = vaults)
return if (vault != null) {
StakingAvailability.Available(StakingOption.P2P(vault))
} else {
StakingAvailability.TemporaryUnavailable
}
}
private suspend fun getVaultsSync(): List<P2PEthPoolVault> {
return p2pEthPoolVaultsStore.getSync()
}
private fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
private fun findPublicVault(vaults: List<P2PEthPoolVault>): P2PEthPoolVault? {
return vaults.firstOrNull { vault -> !vault.isPrivate }
}
}

View file

@ -4,15 +4,15 @@ import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.repositories.StakingActionRepository
import com.tangem.domain.staking.repositories.StakeKitActionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
internal class DefaultStakingActionRepository(
internal class DefaultStakeKitActionRepository(
private val stakingActionsStore: StakingActionsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : StakingActionRepository {
) : StakeKitActionRepository {
override suspend fun store(
userWalletId: UserWalletId,

View file

@ -0,0 +1,422 @@
package com.tangem.data.staking
import android.util.Base64
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toCompressedPublicKey
import com.tangem.data.staking.converters.YieldConverter
import com.tangem.data.staking.converters.action.ActionStatusConverter
import com.tangem.data.staking.converters.action.EnterActionResponseConverter
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.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.*
import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.token.converter.StakingNetworkTypeConverter
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.repositories.StakeKitRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import timber.log.Timber
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakeKitRepository(
private val stakeKitApi: StakeKitApi,
private val stakingYieldsStore: StakingYieldsStore,
private val dispatchers: CoroutineDispatcherProvider,
private val walletManagersFacade: WalletManagersFacade,
moshi: Moshi,
) : StakeKitRepository {
private val transactionStatusConverter = StakingTransactionStatusConverter()
private val transactionTypeConverter = StakingTransactionTypeConverter()
private val actionStatusConverter = ActionStatusConverter()
private val transactionConverter = StakingTransactionConverter(
transactionStatusConverter = transactionStatusConverter,
transactionTypeConverter = transactionTypeConverter,
)
private val enterActionResponseConverter = EnterActionResponseConverter(
actionStatusConverter = actionStatusConverter,
transactionConverter = transactionConverter,
)
private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) }
private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) }
private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) }
override suspend fun fetchYields() {
withContext(dispatchers.io) {
val yieldsResponses = getAvailableStakeKitIntegrationsIds().map {
async { it.getYieldRequest() }
}.awaitAll()
val yields = yieldsResponses.flatMap { response ->
when (response) {
is ApiResponse.Success -> response.data.data.filter { yield -> yield.isAvailable == true }
is ApiResponse.Error -> {
Timber.e("Error fetching enabled yields: ${response.cause}")
emptyList()
}
}
}
val shouldRewriteCache = yieldsResponses.all { it is ApiResponse.Success }
stakingYieldsStore.store(yields, shouldRewriteCache)
}
}
override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield {
return withContext(dispatchers.io) {
val rawCurrencyId = cryptoCurrencyId.rawCurrencyId ?: error("Staking custom tokens is not available")
val prefetchedYield = findPrefetchedYield(
yields = getEnabledYieldsSync(),
currencyId = rawCurrencyId,
symbol = symbol,
)
prefetchedYield ?: error("Staking is unavailable")
}
}
override suspend fun getYield(yieldId: String): Yield {
return withContext(dispatchers.io) {
getEnabledYieldsSync().find { it.id == yieldId } ?: error("Staking is unavailable")
}
}
override suspend fun getActions(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
networkType: NetworkType,
stakingActionStatus: StakingActionStatus,
): List<StakingAction> {
return withContext(dispatchers.io) {
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
val networkTypeDto = StakingNetworkTypeConverter.convertBack(networkType)
val networkTypeString = networkTypeDto.extractJsonName()
val actionStatusDTO = actionStatusConverter.convertBack(stakingActionStatus)
val actionStatusString = actionStatusDTO.extractJsonName()
enterActionResponseConverter.convertListIgnoreErrors(
input = stakeKitApi.getActions(
walletAddress = address,
network = networkTypeString,
status = actionStatusString,
).getOrThrow().data,
onError = { Timber.e("Error converting staking actions list: $it") },
)
}
}
private suspend fun StakingIntegrationID.StakeKit.getYieldRequest(): ApiResponse<EnabledYieldsResponse> {
return when (this) {
is StakingIntegrationID.StakeKit.Coin -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
network = networkId,
)
is StakingIntegrationID.StakeKit.EthereumToken -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
yieldId = value,
network = networkId,
)
}
}
private fun getAvailableStakeKitIntegrationsIds(): List<StakingIntegrationID.StakeKit> {
return StakingIntegrationID.StakeKit.entries
// load all integrations for now and filter in use cases if needed
// .filterNot {
// it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled
// }
}
private fun NetworkTypeDTO.extractJsonName(): String {
return networkTypeAdapter.toJson(this).replace("\"", "")
}
private fun StakingActionStatusDTO.extractJsonName(): String {
return stakingActionStatusAdapter.toJson(this).replace("\"", "")
}
override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo {
return withContext(dispatchers.io) {
val yield = getYield(cryptoCurrencyId, symbol)
StakingEntryInfo(
tokenSymbol = yield.token.symbol,
)
}
}
override suspend fun createAction(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): StakingAction {
return withContext(dispatchers.io) {
val response = when (params.actionCommonType) {
is StakingActionCommonType.Enter -> stakeKitApi.createEnterAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Exit -> stakeKitApi.createExitAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Pending -> stakeKitApi.createPendingAction(
createPendingActionRequestBody(params),
)
}
enterActionResponseConverter.convert(response.getOrThrow())
}
}
override suspend fun estimateGas(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): StakingGasEstimate {
return withContext(dispatchers.io) {
val gasEstimateDTO = when (params.actionCommonType) {
is StakingActionCommonType.Enter -> stakeKitApi.estimateGasOnEnter(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Exit -> stakeKitApi.estimateGasOnExit(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Pending -> stakeKitApi.estimateGasOnPending(
createPendingActionRequestBody(params),
)
}
GasEstimateConverter.convert(gasEstimateDTO.getOrThrow())
}
}
override suspend fun constructTransaction(
networkId: String,
fee: Fee,
amount: Amount,
transactionId: String,
): Pair<StakingTransaction, TransactionData.Compiled> {
return withContext(dispatchers.io) {
val transactionResponse = stakeKitApi.constructTransaction(
transactionId = transactionId,
body = ConstructTransactionRequestBody(),
)
val transaction = transactionConverter.convert(transactionResponse.getOrThrow())
val unsignedTransaction =
transaction.unsignedTransaction ?: error("No unsigned transaction available")
val transactionData = TransactionData.Compiled(
value = getTransactionDataType(networkId, unsignedTransaction),
fee = fee,
amount = amount,
status = TransactionStatus.Unconfirmed,
)
transaction to transactionData
}
}
private suspend fun createActionRequestBody(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): ActionRequestBody {
return ActionRequestBody(
integrationId = params.integrationId,
addresses = Address(
address = params.address,
additionalAddresses = createAdditionalAddresses(userWalletId, network, params),
),
args = ActionRequestBodyArgs(
amount = params.amount.toPlainString(),
inputToken = YieldTokenConverter.convertBack(params.token),
validatorAddress = params.validatorAddress,
validatorAddresses = listOf(params.validatorAddress), // check on other networks
tronResource = getTronResource(network),
),
)
}
private fun createPendingActionRequestBody(params: ActionParams): PendingActionRequestBody {
return PendingActionRequestBody(
integrationId = params.integrationId,
type = params.type ?: StakingActionType.UNKNOWN,
passthrough = params.passthrough.orEmpty(),
args = ActionRequestBodyArgs(
amount = params.amount.toPlainString(),
validatorAddress = params.validatorAddress,
validatorAddresses = listOf(params.validatorAddress),
),
)
}
private suspend fun createAdditionalAddresses(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): Address.AdditionalAddresses? {
val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
return when (params.token.network) {
NetworkType.COSMOS -> Address.AdditionalAddresses(
cosmosPubKey = Base64.encodeToString(
/* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(),
/* flags = */ Base64.NO_WRAP,
),
)
else -> null
}
}
private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data {
return when (Blockchain.fromId(networkId)) {
Blockchain.Solana,
Blockchain.Cosmos,
-> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes())
Blockchain.BSC,
Blockchain.Ethereum,
Blockchain.TON,
Blockchain.Cardano,
-> TransactionData.Compiled.Data.RawString(unsignedTransaction)
Blockchain.Tron -> {
val tronStakeKitTransaction = tronStakeKitTransactionAdapter.fromJson(unsignedTransaction)
?: error("Failed to parse Tron StakeKit transaction")
TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex)
}
else -> error("Unsupported blockchain")
}
}
private fun findPrefetchedYield(yields: List<Yield>, currencyId: CryptoCurrency.RawID, symbol: String): Yield? {
return yields.find { yield ->
yield.tokens.any { it.coinGeckoId == currencyId.value && it.symbol == symbol }
}
}
private suspend fun getEnabledYieldsSync(): List<Yield> {
return YieldConverter.convertListIgnoreErrors(
input = stakingYieldsStore.getSync(),
onError = { Timber.e("Error converting one of the items in enabled yields: $it") },
)
}
override fun getEnabledYields(): Flow<List<Yield>> {
return stakingYieldsStore.get().map { yields ->
YieldConverter.convertListIgnoreErrors(
input = yields,
onError = { Timber.e("Error converting one of the items in enabled yields: $it") },
)
}
}
override fun getStakingAvailability(
rawCurrencyId: CryptoCurrency.RawID,
symbol: String,
): Flow<StakingAvailability> {
return getEnabledYields()
.distinctUntilChanged()
.map { yields ->
if (yields.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable
}
val prefetchedYield = findPrefetchedYield(
yields = yields,
currencyId = rawCurrencyId,
symbol = symbol,
)
if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
} else {
StakingAvailability.TemporaryUnavailable
}
}
}
override suspend fun getStakingAvailabilitySync(
rawCurrencyId: CryptoCurrency.RawID,
symbol: String,
): StakingAvailability {
val yields = getEnabledYieldsSync()
if (yields.isEmpty()) {
return StakingAvailability.TemporaryUnavailable
}
val prefetchedYield = findPrefetchedYield(
yields = yields,
currencyId = rawCurrencyId,
symbol = symbol,
)
return if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
} else {
StakingAvailability.TemporaryUnavailable
}
}
private fun getTronResource(network: Network): TronResource? {
val blockchain = Blockchain.fromNetworkId(network.backendId)
return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) {
TronResource.ENERGY
} else {
null
}
}
}

View file

@ -6,16 +6,16 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata
import com.tangem.domain.staking.repositories.StakingTransactionHashRepository
import com.tangem.domain.staking.repositories.StakeKitTransactionHashRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
internal class DefaultStakingTransactionHashRepository(
internal class DefaultStakeKitTransactionHashRepository(
private val stakeKitApi: StakeKitApi,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : StakingTransactionHashRepository {
) : StakeKitTransactionHashRepository {
override suspend fun submitHash(transactionId: String, transactionHash: String) {
withContext(dispatchers.io) {

View file

@ -29,6 +29,13 @@ internal class DefaultStakingErrorResolver(
is StakingError.DomainError -> {
analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error))
}
// P2P errors
is StakingError.InvalidAmount,
is StakingError.DataError,
is StakingError.UnknownError,
-> {
// P2P errors - no specific analytics event yet
}
}
return error

View file

@ -1,55 +1,19 @@
package com.tangem.data.staking
import android.util.Base64
import arrow.core.getOrElse
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toCompressedPublicKey
import com.tangem.data.staking.converters.YieldConverter
import com.tangem.data.staking.converters.action.ActionStatusConverter
import com.tangem.data.staking.converters.action.EnterActionResponseConverter
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.store.YieldsBalancesStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.*
import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.token.converter.StakingNetworkTypeConverter
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.repositories.StakeKitRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -57,155 +21,22 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.lib.crypto.BlockchainUtils.isCardano
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.withContext
import timber.log.Timber
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakingRepository(
private val stakeKitApi: StakeKitApi,
private val stakingYieldsStore: StakingYieldsStore,
private val stakeKitRepository: StakeKitRepository,
private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingBalanceStoreV2: YieldsBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val walletManagersFacade: WalletManagersFacade,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
moshi: Moshi,
private val walletManagersFacade: WalletManagersFacade,
) : StakingRepository {
private val transactionStatusConverter = StakingTransactionStatusConverter()
private val transactionTypeConverter = StakingTransactionTypeConverter()
private val actionStatusConverter = ActionStatusConverter()
private val transactionConverter = StakingTransactionConverter(
transactionStatusConverter = transactionStatusConverter,
transactionTypeConverter = transactionTypeConverter,
)
private val enterActionResponseConverter = EnterActionResponseConverter(
actionStatusConverter = actionStatusConverter,
transactionConverter = transactionConverter,
)
private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) }
private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) }
private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) }
override suspend fun fetchEnabledYields() {
withContext(dispatchers.io) {
val yieldsResponses = getAvailableIntegrationsIds().map {
async { it.getYieldRequest() }
}.awaitAll()
val yields = yieldsResponses.flatMap { response ->
when (response) {
is ApiResponse.Success -> response.data.data.filter { yield -> yield.isAvailable == true }
else -> {
Timber.e("Error fetching enabled yields: ${(response as? ApiResponse.Error)?.cause}")
emptyList()
}
}
}
val shouldRewriteCache = yieldsResponses.all { it is ApiResponse.Success }
stakingYieldsStore.store(yields, shouldRewriteCache)
}
}
override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield {
return withContext(dispatchers.io) {
val rawCurrencyId = cryptoCurrencyId.rawCurrencyId ?: error("Staking custom tokens is not available")
val prefetchedYield = findPrefetchedYield(
yields = getEnabledYieldsSync(),
currencyId = rawCurrencyId,
symbol = symbol,
)
prefetchedYield ?: error("Staking is unavailable")
}
}
override suspend fun getYield(yieldId: String): Yield {
return withContext(dispatchers.io) {
getEnabledYieldsSync().find { it.id == yieldId } ?: error("Staking is unavailable")
}
}
override suspend fun getActions(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
networkType: NetworkType,
stakingActionStatus: StakingActionStatus,
): List<StakingAction> {
return withContext(dispatchers.io) {
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
val networkTypeDto = StakingNetworkTypeConverter.convertBack(networkType)
val networkTypeString = networkTypeDto.extractJsonName()
val actionStatusDTO = actionStatusConverter.convertBack(stakingActionStatus)
val actionStatusString = actionStatusDTO.extractJsonName()
enterActionResponseConverter.convertListIgnoreErrors(
input = stakeKitApi.getActions(
walletAddress = address,
network = networkTypeString,
status = actionStatusString,
).getOrThrow().data,
onError = { Timber.e("Error converting staking actions list: $it") },
)
}
}
private suspend fun StakingIntegrationID.getYieldRequest(): ApiResponse<EnabledYieldsResponse> {
return when (this) {
is StakingIntegrationID.Coin -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
network = networkId,
)
is StakingIntegrationID.EthereumToken -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
yieldId = value,
network = networkId,
)
}
}
private fun getAvailableIntegrationsIds(): List<StakingIntegrationID> {
return StakingIntegrationID.entries
// load all integrations for now and filter in use cases if needed
// .filterNot {
// it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled
// }
}
private fun NetworkTypeDTO.extractJsonName(): String {
return networkTypeAdapter.toJson(this).replace("\"", "")
}
private fun StakingActionStatusDTO.extractJsonName(): String {
return stakingActionStatusAdapter.toJson(this).replace("\"", "")
}
override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo {
return withContext(dispatchers.io) {
val yield = getYield(cryptoCurrencyId, symbol)
StakingEntryInfo(
rewardInfo = requireNotNull(
yield
.preferredValidators
.maxByOrNull { it.rewardInfo?.rate.orZero() }?.rewardInfo,
),
rewardSchedule = yield.metadata.rewardSchedule,
tokenSymbol = yield.token.symbol,
)
}
}
override fun getStakingAvailability(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
@ -227,32 +58,18 @@ internal class DefaultStakingRepository(
return@channelFlow
}
val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
getEnabledYields()
.distinctUntilChanged()
.onEach { yields ->
if (yields.isEmpty()) {
send(StakingAvailability.TemporaryUnavailable)
return@onEach
}
val availabilityFlow = when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
rawCurrencyId,
cryptoCurrency.symbol,
)
null -> flowOf(StakingAvailability.Unavailable)
}
val prefetchedYield = findPrefetchedYield(
yields = yields,
currencyId = rawCurrencyId,
symbol = cryptoCurrency.symbol,
)
when {
prefetchedYield != null && isSupportedInMobileApp -> {
send(StakingAvailability.Available(prefetchedYield))
}
prefetchedYield == null && isSupportedInMobileApp -> {
send(StakingAvailability.TemporaryUnavailable)
}
else -> send(StakingAvailability.Unavailable)
}
}
.launchIn(this)
availabilityFlow.collect { send(it) }
}
}
@ -273,33 +90,36 @@ internal class DefaultStakingRepository(
return StakingAvailability.Unavailable
}
val isSupportedInMobileApp = StakingIntegrationID.create(currencyId = cryptoCurrency.id) != null
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?: return StakingAvailability.Unavailable
val yields = getEnabledYieldsSync()
if (yields.isEmpty()) {
return StakingAvailability.TemporaryUnavailable
return when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync(
rawCurrencyId,
cryptoCurrency.symbol,
)
}
}
val prefetchedYield = findPrefetchedYield(
yields = yields,
currencyId = rawCurrencyId,
symbol = cryptoCurrency.symbol,
)
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
return when {
prefetchedYield != null && isSupportedInMobileApp -> {
StakingAvailability.Available(prefetchedYield)
val hasDataYieldBalance by lazy {
balances.any { yieldBalance ->
(yieldBalance as? YieldBalance.Data)?.balance?.items?.isNotEmpty() == true
}
}
prefetchedYield == null && isSupportedInMobileApp -> {
StakingAvailability.TemporaryUnavailable
}
else -> StakingAvailability.Unavailable
balances.isNotEmpty() && hasDataYieldBalance
}
}
private suspend fun checkFeatureToggleEnabled(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
return when (cryptoCurrency.network.id.toBlockchain()) {
Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled
Blockchain.Ethereum -> stakingFeatureToggles.isEthStakingEnabled
Blockchain.Cardano -> {
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
val balance = stakingBalanceStoreV2.getSyncOrNull(
@ -337,208 +157,6 @@ internal class DefaultStakingRepository(
}
}
override suspend fun createAction(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): StakingAction {
return withContext(dispatchers.io) {
val response = when (params.actionCommonType) {
is StakingActionCommonType.Enter -> stakeKitApi.createEnterAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Exit -> stakeKitApi.createExitAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Pending -> stakeKitApi.createPendingAction(
createPendingActionRequestBody(params),
)
}
enterActionResponseConverter.convert(response.getOrThrow())
}
}
override suspend fun estimateGas(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): StakingGasEstimate {
return withContext(dispatchers.io) {
val gasEstimateDTO = when (params.actionCommonType) {
is StakingActionCommonType.Enter -> stakeKitApi.estimateGasOnEnter(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Exit -> stakeKitApi.estimateGasOnExit(
createActionRequestBody(
userWalletId,
network,
params,
),
)
is StakingActionCommonType.Pending -> stakeKitApi.estimateGasOnPending(
createPendingActionRequestBody(params),
)
}
GasEstimateConverter.convert(gasEstimateDTO.getOrThrow())
}
}
override suspend fun constructTransaction(
networkId: String,
fee: Fee,
amount: Amount,
transactionId: String,
): Pair<StakingTransaction, TransactionData.Compiled> {
return withContext(dispatchers.io) {
val transactionResponse = stakeKitApi.constructTransaction(
transactionId = transactionId,
body = ConstructTransactionRequestBody(),
)
val transaction = transactionConverter.convert(transactionResponse.getOrThrow())
val unsignedTransaction =
transaction.unsignedTransaction ?: error("No unsigned transaction available")
val transactionData = TransactionData.Compiled(
value = getTransactionDataType(networkId, unsignedTransaction),
fee = fee,
amount = amount,
status = TransactionStatus.Unconfirmed,
)
transaction to transactionData
}
}
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
val hasDataYieldBalance by lazy {
balances.any { yieldBalance ->
(yieldBalance as? YieldBalance.Data)?.balance?.items?.isNotEmpty() == true
}
}
balances.isNotEmpty() && hasDataYieldBalance
}
}
private suspend fun createActionRequestBody(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): ActionRequestBody {
return ActionRequestBody(
integrationId = params.integrationId,
addresses = Address(
address = params.address,
additionalAddresses = createAdditionalAddresses(userWalletId, network, params),
),
args = ActionRequestBodyArgs(
amount = params.amount.toPlainString(),
inputToken = YieldTokenConverter.convertBack(params.token),
validatorAddress = params.validatorAddress,
validatorAddresses = listOf(params.validatorAddress), // check on other networks
tronResource = getTronResource(network),
),
)
}
private fun createPendingActionRequestBody(params: ActionParams): PendingActionRequestBody {
return PendingActionRequestBody(
integrationId = params.integrationId,
type = params.type ?: StakingActionType.UNKNOWN,
passthrough = params.passthrough.orEmpty(),
args = ActionRequestBodyArgs(
amount = params.amount.toPlainString(),
validatorAddress = params.validatorAddress,
validatorAddresses = listOf(params.validatorAddress),
),
)
}
private suspend fun createAdditionalAddresses(
userWalletId: UserWalletId,
network: Network,
params: ActionParams,
): Address.AdditionalAddresses? {
val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
return when (params.token.network) {
NetworkType.COSMOS -> Address.AdditionalAddresses(
cosmosPubKey = Base64.encodeToString(
/* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(),
/* flags = */ Base64.NO_WRAP,
),
)
else -> null
}
}
private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data {
return when (Blockchain.fromId(networkId)) {
Blockchain.Solana,
Blockchain.Cosmos,
-> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes())
Blockchain.BSC,
Blockchain.Ethereum,
Blockchain.TON,
Blockchain.Cardano,
-> TransactionData.Compiled.Data.RawString(unsignedTransaction)
Blockchain.Tron -> {
val tronStakeKitTransaction = tronStakeKitTransactionAdapter.fromJson(unsignedTransaction)
?: error("Failed to parse Tron StakeKit transaction")
TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex)
}
else -> error("Unsupported blockchain")
}
}
private fun findPrefetchedYield(yields: List<Yield>, currencyId: CryptoCurrency.RawID, symbol: String): Yield? {
return yields.find { yield ->
yield.tokens.any { it.coinGeckoId == currencyId.value && it.symbol == symbol }
}
}
private suspend fun getEnabledYieldsSync(): List<Yield> {
return YieldConverter.convertListIgnoreErrors(
input = stakingYieldsStore.getSync(),
onError = { Timber.e("Error converting one of the items in enabled yields: $it") },
)
}
override fun getEnabledYields(): Flow<List<Yield>> {
return stakingYieldsStore.get().map {
YieldConverter.convertListIgnoreErrors(
input = it,
onError = { Timber.e("Error converting one of the items in enabled yields: $it") },
)
}
}
private fun getTronResource(network: Network): TronResource? {
val blockchain = Blockchain.fromNetworkId(network.backendId)
return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) {
TronResource.ENERGY
} else {
null
}
}
companion object {
private val INVALID_BATCHES_FOR_SOLANA = listOf("AC01", "CB79")
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.utils.converter.Converter
import org.joda.time.Instant
/**
* Converter from P2P Account Info Response to Domain model
*/
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount {
return P2PEthPoolAccount(
delegatorAddress = value.delegatorAddress,
vaultAddress = value.vaultAddress,
stake = convertStake(value.stake),
availableToUnstake = value.availableToUnstake,
availableToWithdraw = value.availableToWithdraw,
exitQueue = convertExitQueue(value.exitQueue),
)
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
return P2PEthPoolStake(
assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets,
)
}
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
return P2PEthPoolExitQueue(
total = dto.total.toBigDecimal(),
requests = dto.requests.map(::convertExitRequest),
)
}
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
return P2PEthPoolExitRequest(
ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.ofEpochSecond(dto.timestamp),
withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp),
isClaimable = dto.isClaimable,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolBroadcastResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTxStatusDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastStatus
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Broadcast Transaction Response to Domain model
*/
internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> {
override fun convert(value: P2PEthPoolBroadcastResponse): P2PEthPoolBroadcastResult {
return P2PEthPoolBroadcastResult(
hash = value.hash,
status = convertStatus(value.status),
blockNumber = value.blockNumber,
transactionIndex = value.transactionIndex,
gasUsed = value.gasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
cumulativeGasUsed = value.cumulativeGasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
effectiveGasPrice = value.effectiveGasPrice?.toBigDecimalOrNull(),
from = value.from,
to = value.to,
)
}
private fun convertStatus(status: P2PEthPoolTxStatusDTO): P2PEthPoolBroadcastStatus {
return when (status) {
P2PEthPoolTxStatusDTO.SUCCESS -> P2PEthPoolBroadcastStatus.SUCCESS
P2PEthPoolTxStatusDTO.FAILED -> P2PEthPoolBroadcastStatus.FAILED
}
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorDetailsDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorResponse
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Error Response to Domain StakingError
*/
@Suppress("MagicNumber")
internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> {
override fun convert(value: P2PEthPoolErrorResponse): StakingError {
return convertFromErrorDetails(value.error)
}
fun convertFromErrorDetails(details: P2PEthPoolErrorDetailsDTO): StakingError {
return when (details.code) {
// Authentication errors
101111 -> StakingError.UnknownError(
Exception("Missing Bearer token: ${details.message}"),
)
101109 -> StakingError.UnknownError(
Exception("Invalid Bearer token: ${details.message}"),
)
101110 -> StakingError.UnknownError(
Exception("Server authorization error: ${details.message}"),
)
// Validation errors
100101 -> StakingError.InvalidAmount(details.message)
// Withdrawal errors
127104 -> StakingError.DataError(
IllegalStateException("No withdrawable balance: ${details.message}"),
)
127105 -> StakingError.UnknownError(
Exception("Gas amount too low: ${details.message}"),
)
127106 -> StakingError.InvalidAmount("Invalid delegator address: ${details.message}")
127107 -> StakingError.UnknownError(
Exception("Gas price too low: ${details.message}"),
)
127108 -> StakingError.UnknownError(
Exception("Transaction simulation failed: ${details.message}"),
)
// Vault errors
127101 -> StakingError.DataError(
IllegalStateException("Invalid vault: ${details.message}"),
)
// Account errors
124108 -> StakingError.DataError(
IllegalStateException("Invalid delegator: ${details.message}"),
)
// Network errors
127100 -> StakingError.DataError(
IllegalStateException("Unsupported network: ${details.message}"),
)
else -> StakingError.UnknownError(
Exception("${details.code}: ${details.message}"),
)
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Reward Entry DTO to Domain model
*/
internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> {
override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward {
return P2PEthPoolReward(
date = value.date,
apy = value.apy.toBigDecimal(),
balance = value.balance,
rewards = value.rewards,
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolUnsignedTxDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Unsigned Transaction DTO to Domain model
*/
internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> {
override fun convert(value: P2PEthPoolUnsignedTxDTO): P2PEthPoolUnsignedTx {
return P2PEthPoolUnsignedTx(
serializeTx = value.serializeTx,
to = value.to,
data = value.data,
value = value.value.toBigDecimalOrNull() ?: BigDecimal.ZERO,
nonce = value.nonce,
chainId = value.chainId,
gasLimit = value.gasLimit,
maxFeePerGas = value.maxFeePerGas,
maxPriorityFeePerGas = value.maxPriorityFeePerGas,
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Vault DTO to Domain model
*/
internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> {
override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault {
return P2PEthPoolVault(
vaultAddress = value.vaultAddress,
displayName = value.displayName,
apy = value.apy.toBigDecimal(),
baseApy = value.baseApy.toBigDecimal(),
capacity = value.capacity.toBigDecimal(),
totalAssets = value.totalAssets.toBigDecimal(),
feePercent = value.feePercent.toBigDecimal(),
isPrivate = value.isPrivate,
isGenesis = value.isGenesis,
isSmoothingPool = value.isSmoothingPool,
isErc20 = value.isErc20,
tokenName = value.tokenName,
tokenSymbol = value.tokenSymbol,
createdAt = value.createdAt,
)
}
}

View file

@ -3,24 +3,20 @@ package com.tangem.data.staking.di
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.staking.DefaultStakingActionRepository
import com.tangem.data.staking.DefaultStakingErrorResolver
import com.tangem.data.staking.DefaultStakingRepository
import com.tangem.data.staking.DefaultStakingTransactionHashRepository
import com.tangem.data.staking.*
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
import com.tangem.data.staking.utils.DefaultStakingCleaner
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.staking.repositories.StakingActionRepository
import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.repositories.StakingTransactionHashRepository
import com.tangem.domain.staking.repositories.*
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,36 +34,66 @@ internal object StakingDataModule {
@Provides
@Singleton
fun provideStakingRepository(
fun provideStakeKitRepository(
stakeKitApi: StakeKitApi,
stakingYieldsStore: StakingYieldsStore,
yieldsBalancesStore: YieldsBalancesStore,
dispatchers: CoroutineDispatcherProvider,
walletManagersFacade: WalletManagersFacade,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
@NetworkMoshi moshi: Moshi,
): StakingRepository {
return DefaultStakingRepository(
): StakeKitRepository {
return DefaultStakeKitRepository(
stakeKitApi = stakeKitApi,
stakingYieldsStore = stakingYieldsStore,
stakingBalanceStoreV2 = yieldsBalancesStore,
dispatchers = dispatchers,
walletManagersFacade = walletManagersFacade,
getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles,
moshi = moshi,
)
}
@Provides
@Singleton
fun provideStakingRepository(
stakeKitRepository: StakeKitRepository,
p2pEthPoolRepository: P2PEthPoolRepository,
yieldsBalancesStore: YieldsBalancesStore,
dispatchers: CoroutineDispatcherProvider,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
walletManagersFacade: WalletManagersFacade,
): StakingRepository {
return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository,
p2pEthPoolRepository = p2pEthPoolRepository,
stakingBalanceStoreV2 = yieldsBalancesStore,
dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase,
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
}
@Provides
@Singleton
fun provideP2PEthPoolRepository(
p2pApi: P2PEthPoolApi,
p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
dispatchers: CoroutineDispatcherProvider,
): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository(
p2pApi = p2pApi,
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideStakingTransactionHashRepository(
stakeKitApi: StakeKitApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): StakingTransactionHashRepository {
return DefaultStakingTransactionHashRepository(
): StakeKitTransactionHashRepository {
return DefaultStakeKitTransactionHashRepository(
stakeKitApi = stakeKitApi,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
@ -79,8 +105,8 @@ internal object StakingDataModule {
fun provideStakingActionRepository(
stakingActionsStore: StakingActionsStore,
dispatchers: CoroutineDispatcherProvider,
): StakingActionRepository {
return DefaultStakingActionRepository(
): StakeKitActionRepository {
return DefaultStakeKitActionRepository(
stakingActionsStore = stakingActionsStore,
dispatchers = dispatchers,
)

View file

@ -69,8 +69,8 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
fetch(userWalletId = params.userWalletId, stakingIds = availableStakingIds)
}
.onLeft {
Timber.e(it, "Unable to fetch yield balances $params")
.onLeft { throwable ->
Timber.e(throwable, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
}
@ -163,21 +163,21 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
if (!allResponsesReceived(requests, yieldBalances)) {
val values = stakingIds.filter { stakingId ->
yieldBalances.none {
stakingId.integrationId == it.integrationId &&
stakingId.address == it.addresses.address
yieldBalances.none { balanceWrapper ->
stakingId.integrationId == balanceWrapper.integrationId &&
stakingId.address == balanceWrapper.addresses.address
}
}
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet())
}
},
onError = {
Timber.e(it, "Unable to fetch yield balances $userWalletId")
onError = { throwable ->
Timber.e(throwable, "Unable to fetch yield balances $userWalletId")
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw it
throw throwable
},
)
}
@ -187,9 +187,9 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
yieldBalances: Set<YieldBalanceWrapperDTO>,
): Boolean {
return requests.all { request ->
yieldBalances.any {
request.integrationId == it.integrationId &&
request.addresses.address == it.addresses.address
yieldBalances.any { balance ->
request.integrationId == balance.integrationId &&
request.addresses.address == balance.addresses.address
}
}
}

View file

@ -12,4 +12,7 @@ internal class DefaultStakingFeatureToggles(
override val isCardanoStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("STAKING_CARDANO_ENABLED")
override val isEthStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED")
}

View file

@ -14,7 +14,7 @@ internal object YieldBalanceRequestBodyFactory {
return YieldBalanceRequestBody(
addresses = YieldBalanceRequestBodyAddressFactory.create(stakingID),
args = YieldBalanceRequestBody.YieldBalanceRequestArgs(
validatorAddresses = listOf(), // todo add validators [REDACTED_JIRA]
validatorAddresses = emptyList(), // todo add validators [REDACTED_JIRA]
),
integrationId = stakingID.integrationId,
)

View file

@ -4,8 +4,6 @@ import arrow.core.toOption
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
@ -17,6 +15,8 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest

View file

@ -2,13 +2,13 @@ package com.tangem.data.staking.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk

View file

@ -2,7 +2,6 @@ package com.tangem.data.staking.single
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.staking.StakingID
@ -11,6 +10,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every

View file

@ -3,11 +3,11 @@ package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test

View file

@ -23,7 +23,7 @@ class DefaultStakingCleanerTest {
)
private val userWalletId = UserWalletId("011")
private val stakingIds = setOf(
StakingID(integrationId = StakingIntegrationID.Coin.Cardano.value, address = "0x1"),
StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"),
)
@BeforeEach