From 016b1e18d2085092ef1dbf22a722f9d928ea7b84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Dec 2025 14:55:04 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../staking/DefaultP2PEthPoolRepository.kt | 2 +- .../staking/model/StakingIntegration.kt | 2 +- .../repositories/P2PEthPoolRepository.kt | 10 + features/staking/impl/build.gradle.kts | 1 + .../impl/presentation/model/StakingModel.kt | 111 +++--- .../state/helpers/P2PEthPoolFeeLoader.kt | 55 +++ .../helpers/P2PEthPoolTransactionCreator.kt | 95 ++++++ .../helpers/P2PEthPoolTransactionSender.kt | 128 +++++++ ...nsactionLoader.kt => StakeKitFeeLoader.kt} | 172 ++++++---- .../helpers/StakeKitTransactionSender.kt | 322 ++++++++++++++++++ .../state/helpers/StakingFeeLoader.kt | 16 + .../state/helpers/StakingOperationsFactory.kt | 55 +++ .../state/helpers/StakingTransactionSender.kt | 310 +---------------- 13 files changed, 858 insertions(+), 421 deletions(-) create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt rename features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/{StakingFeeTransactionLoader.kt => StakeKitFeeLoader.kt} (63%) create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 4a7f708311..46eae8ecb3 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -248,7 +248,7 @@ internal class DefaultP2PEthPoolRepository( } } - private suspend fun getVaultsSync(): List { + override suspend fun getVaultsSync(): List { return p2pEthPoolVaultsStore.getSync() } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index 37f76ede7f..48f78ec663 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -11,7 +11,7 @@ import java.math.BigDecimal * Strategy interface for staking integrations. * Abstracts over StakeKit and P2PEthPool staking providers. */ -interface StakingIntegration { +sealed interface StakingIntegration { // Basic diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index 2b3811a26a..04892f5414 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -132,6 +132,16 @@ interface P2PEthPoolRepository { */ fun getVaultsFlow(): Flow> + /** + * Get cached vaults synchronously from local store. + * + * This returns vaults from the local cache/store without network call. + * Call [fetchVaults] first to populate the cache from the network. + * + * @return List of cached vaults (empty if cache is not populated) + */ + suspend fun getVaultsSync(): List + /** * Check P2PEthPool staking availability by finding public vault * diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index c647e6e760..92180a4384 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(deps.lifecycle.compose) implementation(deps.jodatime) implementation(deps.timber) + implementation(deps.moshi) /** Compose */ implementation(deps.compose.accompanist.systemUiController) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 30806bf543..07da487fa9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -69,7 +69,8 @@ import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM import com.tangem.features.staking.impl.presentation.state.events.StakingEvent import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater -import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeTransactionLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender import com.tangem.features.staking.impl.presentation.state.transformers.* import com.tangem.features.staking.impl.presentation.state.transformers.amount.* @@ -128,8 +129,7 @@ internal class StakingModel @Inject constructor( private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, - private val stakingTransactionLoader: StakingTransactionSender.Factory, - private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory, + private val stakingOperationsFactory: StakingOperationsFactory, private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, @@ -175,8 +175,7 @@ internal class StakingModel @Inject constructor( StakeKitIntegration(integrationId, yield) } StakingIntegrationID.P2PEthPool -> { - // TODO p2p avoid network call - val vaults = p2pEthPoolRepository.getVaults().getOrElse { emptyList() } + val vaults = p2pEthPoolRepository.getVaultsSync() P2PEthPoolIntegration(integrationId, vaults) } } @@ -221,16 +220,16 @@ internal class StakingModel @Inject constructor( ) } - private val feeLoader by lazy(LazyThreadSafetyMode.NONE) { - stakingFeeTransactionLoader.create( + private val feeLoader: StakingFeeLoader by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createFeeLoader( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, integration = integration, ) } - private val transactionSender by lazy(LazyThreadSafetyMode.NONE) { - stakingTransactionLoader.create( + private val transactionSender: StakingTransactionSender by lazy(LazyThreadSafetyMode.NONE) { + stakingOperationsFactory.createTransactionSender( cryptoCurrencyStatus = cryptoCurrencyStatus, userWallet = userWallet, integration = integration, @@ -391,49 +390,57 @@ internal class StakingModel @Inject constructor( modelScope.launch { stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - transactionSender.constructAndSendTransactions( - onConstructSuccess = { constructedTransactions -> - transactionsInProgress.addAll(constructedTransactions) - }, - onConstructError = { error -> - stakingEventFactory.createStakingErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onSendSuccess = { txUrl -> - stakingAnalyticSender.sendTransactionStakingAnalytics( - stateController.value, - cryptoCurrencyStatus, - ) - transactionsInProgress.clear() - stateController.update(SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus)) - }, - onSendError = { error -> - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = error.getAnalyticsDescription(), - ), - ) - stakingEventFactory.createSendTransactionErrorAlert(error) - stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) - }, - onFeeIncreased = { increasedFee, isFeeApproximate -> - stateController.updateAll( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = increasedFee, - isFeeApproximate = isFeeApproximate, - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stateController.updateEvent( - StakingEvent.ShowAlert( - StakingAlertUM.FeeIncreased(stateController::dismissAlert), - ), - ) - updateNotifications() - }, + transactionSender.send( + StakingTransactionSender.Callbacks( + onConstructSuccess = { constructedTransactions -> + transactionsInProgress.addAll(constructedTransactions) + }, + onConstructError = { error -> + stakingEventFactory.createStakingErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onSendSuccess = { txUrl -> + stakingAnalyticSender.sendTransactionStakingAnalytics( + stateController.value, + cryptoCurrencyStatus, + ) + transactionsInProgress.clear() + stateController.update( + SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus), + ) + }, + onSendError = { error -> + analyticsEventHandler.send( + StakingAnalyticsEvent.TransactionError( + errorCode = error.getAnalyticsDescription(), + ), + ) + stakingEventFactory.createSendTransactionErrorAlert(error) + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + }, + onFeeIncreased = { increasedFee, isFeeApproximate -> + stateController.updateAll( + SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = increasedFee, + isFeeApproximate = isFeeApproximate, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + ) + stateController.updateEvent( + StakingEvent.ShowAlert( + StakingAlertUM.FeeIncreased(stateController::dismissAlert), + ), + ) + updateNotifications() + }, + onTransactionExpired = { + stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) + getFee() + }, + ), ) }.saveIn(sendTransactionJobHolder) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt new file mode 100644 index 0000000000..0f728d1ce5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolFeeLoader.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class P2PEthPoolFeeLoader @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingFeeLoader { + + override suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + transactionCreator.createTransaction(cryptoCurrencyStatus).fold( + ifLeft = onStakingFeeError, + ifRight = { unsignedTx -> + val fee = convertUnsignedTxToFee(unsignedTx) + onStakingFee(fee, false) + }, + ) + } + + private fun convertUnsignedTxToFee(unsignedTx: P2PEthPoolUnsignedTx): Fee { + val blockchain = integration.integrationId.blockchain + val decimals = blockchain.decimals() + val feeInWei = unsignedTx.gasLimit * unsignedTx.maxFeePerGas + val feeValue = feeInWei.movePointLeft(decimals) + return Fee.Common( + Amount( + currencySymbol = blockchain.currency, + value = feeValue, + decimals = decimals, + ), + ) + } + + @AssistedFactory + interface Factory { + + fun create(cryptoCurrencyStatus: CryptoCurrencyStatus, integration: P2PEthPoolIntegration): P2PEthPoolFeeLoader + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt new file mode 100644 index 0000000000..ed2e4f77df --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionCreator.kt @@ -0,0 +1,95 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.Either +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import java.math.BigDecimal +import javax.inject.Inject + +@ModelScoped +internal class P2PEthPoolTransactionCreator @Inject constructor( + private val stateController: StakingStateController, + private val p2pEthPoolRepository: P2PEthPoolRepository, +) { + + suspend fun createTransaction( + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): Either { + val params = extractParams(cryptoCurrencyStatus) + ?: return Either.Left(StakingError.DomainError("Invalid state for transaction creation")) + + return createTransaction( + actionType = params.actionType, + amount = params.amount, + vaultAddress = params.vaultAddress, + sourceAddress = params.sourceAddress, + ) + } + + fun extractParams(cryptoCurrencyStatus: CryptoCurrencyStatus): TransactionParams? { + val state = stateController.value + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: return null + + val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: return null + + val vaultAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + ?: state.balanceState?.targetAddress + ?: return null + + val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: return null + + return TransactionParams( + actionType = state.actionType, + amount = amount, + vaultAddress = vaultAddress, + sourceAddress = sourceAddress, + ) + } + + private suspend fun createTransaction( + actionType: StakingActionCommonType, + amount: BigDecimal, + vaultAddress: String, + sourceAddress: String, + ): Either { + val network = P2PEthPoolStakingConfig.activeNetwork + + return when (actionType) { + is StakingActionCommonType.Enter -> { + p2pEthPoolRepository.createDepositTransaction( + network = network, + delegatorAddress = sourceAddress, + vaultAddress = vaultAddress, + amount = amount.toPlainString(), + ) + } + is StakingActionCommonType.Exit -> { + p2pEthPoolRepository.createWithdrawTransaction( + network = network, + stakerAddress = sourceAddress, + ) + } + is StakingActionCommonType.Pending -> { + Either.Left(StakingError.DomainError("Pending actions not supported for P2PEthPool")) + } + } + } + + data class TransactionParams( + val actionType: StakingActionCommonType, + val amount: BigDecimal, + val vaultAddress: String, + val sourceAddress: String, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt new file mode 100644 index 0000000000..672f9ea61c --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/P2PEthPoolTransactionSender.kt @@ -0,0 +1,128 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.ethereum.models.EthereumCompiledTransaction +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.formatHex +import com.tangem.common.extensions.toHexString +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig +import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.math.BigInteger + +@Suppress("LongParameterList") +internal class P2PEthPoolTransactionSender @AssistedInject constructor( + private val transactionCreator: P2PEthPoolTransactionCreator, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val prepareForSendUseCase: PrepareForSendUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val p2pEthPoolRepository: P2PEthPoolRepository, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: P2PEthPoolIntegration, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + val params = transactionCreator.extractParams(cryptoCurrencyStatus) + ?: run { + callbacks.onConstructError( + StakingError.DomainError("Invalid state for transaction"), + ) + return + } + + val unsignedTx = transactionCreator.createTransaction(cryptoCurrencyStatus).getOrElse { error -> + callbacks.onConstructError(error) + return + } + + val compiledTxJson = createCompiledTransactionJson(unsignedTx, params.sourceAddress) + + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(compiledTxJson), + ) + + val signedTxBytes = prepareForSendUseCase( + transactionData = transactionData, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + ).getOrElse { error -> + callbacks.onSendError(error) + return + } + + val signedTxHex = signedTxBytes.toHexString().lowercase().formatHex() + + p2pEthPoolRepository.broadcastTransaction( + network = P2PEthPoolStakingConfig.activeNetwork, + signedTransaction = signedTxHex, + ).fold( + ifLeft = { error -> + callbacks.onConstructError(error) + }, + ifRight = { broadcastResult -> + val txUrl = getExplorerTransactionUrlUseCase( + txHash = broadcastResult.hash, + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + callbacks.onSendSuccess(txUrl) + }, + ) + } + + private fun createCompiledTransactionJson(unsignedTx: P2PEthPoolUnsignedTx, fromAddress: String): String { + val compiledTx = EthereumCompiledTransaction( + from = fromAddress, + to = unsignedTx.to, + data = unsignedTx.data, + value = unsignedTx.value.toBigInteger().toHexString(), + nonce = unsignedTx.nonce, + chainId = unsignedTx.chainId, + gasLimit = unsignedTx.gasLimit.toBigInteger().toHexString(), + gasPrice = null, // EIP-1559: gasPrice is null + maxFeePerGas = unsignedTx.maxFeePerGas.toBigInteger().toHexString(), + maxPriorityFeePerGas = unsignedTx.maxPriorityFeePerGas.toBigInteger().toHexString(), + type = EIP_1559_TX_TYPE, + ) + return ethereumCompiledTxAdapter.toJson(compiledTx) + } + + private fun BigInteger.toHexString(): String { + val hex = toString(HEX_RADIX) + val paddedHex = if (hex.length % 2 != 0) "0$hex" else hex + return HEX_PREFIX + paddedHex + } + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: P2PEthPoolIntegration, + ): P2PEthPoolTransactionSender + } + + private companion object { + const val HEX_PREFIX = "0x" + const val HEX_RADIX = 16 + const val EIP_1559_TX_TYPE = 2 + + val ethereumCompiledTxAdapter: com.squareup.moshi.JsonAdapter = + Moshi.Builder().build().adapter(EthereumCompiledTransaction::class.java) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt similarity index 63% rename from features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt rename to features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt index 9e5ad3188c..f41f4e4772 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitFeeLoader.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.EstimateGasUseCase -import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.StakeKitIntegration import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams @@ -36,7 +36,7 @@ import kotlinx.coroutines.delay import java.math.BigDecimal @Suppress("LongParameterList") -internal class StakingFeeTransactionLoader @AssistedInject constructor( +internal class StakeKitFeeLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, @@ -44,10 +44,10 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, - @Assisted private val integration: StakingIntegration, -) { + @Assisted private val integration: StakeKitIntegration, +) : StakingFeeLoader { - suspend fun getFee( + override suspend fun getFee( onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, @@ -57,21 +57,41 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data ?: error("Illegal state") - val validatorAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address + val targetAddress = (state.validatorState as? StakingStates.ValidatorState.Data)?.chosenTarget?.address ?: state.balanceState?.targetAddress ?: error("No target address provided") val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: error("No amount provided") - val pendingAction = confirmationState.pendingAction - val pendingActions = confirmationState.pendingActions + getStakeKitFee( + confirmationState = confirmationState, + actionType = state.actionType, + amount = amount, + validatorAddress = targetAddress, + onStakingFee = onStakingFee, + onStakingFeeError = onStakingFeeError, + onApprovalFee = onApprovalFee, + onFeeError = onFeeError, + ) + } - val isEnter = state.actionType is StakingActionCommonType.Enter + private suspend fun getStakeKitFee( + confirmationState: StakingStates.ConfirmationState.Data, + actionType: StakingActionCommonType, + amount: BigDecimal, + validatorAddress: String, + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) { + val isEnter = actionType is StakingActionCommonType.Enter val isApprovalNeeded = confirmationState.isApprovalNeeded val isAllowanceNotEnough = confirmationState.allowance < amount + if (isEnter && isApprovalNeeded && isAllowanceNotEnough) { - getApproveFee( + getApprovalFee( amount = amount, validatorAddress = validatorAddress, onApprovalFee = onApprovalFee, @@ -79,8 +99,8 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) } else { estimateGas( - pendingAction = pendingAction, - pendingActions = pendingActions, + pendingAction = confirmationState.pendingAction, + pendingActions = confirmationState.pendingActions, amount = amount, validatorAddress = validatorAddress, onStakingFeeError = onStakingFeeError, @@ -105,61 +125,80 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( pendingActions = pendingActions, ) ) { - val result = coroutineScope { - pendingActions?.map { action -> - async { - // Simultaneous or quick api calls can sometimes return ZERO fee - estimateFeeRetry { - estimateFee( - amount = amount, - sourceAddress = sourceAddress, - validatorAddress = validatorAddress, - action = action, - ) - }.getOrElse { - onStakingFeeError(it) - null - } - } - }?.awaitAll()?.filterNotNull() - } - - if (result.isNullOrEmpty()) { - onStakingFeeError(StakingError.DomainError("Error estimating fee")) - return - } - - val totalAmount = result.sumOf { it.amount } - val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } - StakingGasEstimate( - amount = totalAmount, - token = result.first().token, - gasLimit = totalGasLimit.toPlainString().orEmpty(), - ) + estimateCompositeGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + onStakingFeeError = onStakingFeeError, + ) ?: return } else { - estimateFee( + estimateSingleGas( amount = amount, sourceAddress = sourceAddress, validatorAddress = validatorAddress, action = pendingAction, - ).getOrElse { - onStakingFeeError(it) + ).getOrElse { error -> + onStakingFeeError(error) return } } - val amount = Amount( + val feeAmount = Amount( currencySymbol = gasEstimate.token.symbol, value = gasEstimate.amount, decimals = gasEstimate.token.decimals, ) onStakingFee( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + Fee.Common(feeAmount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = feeAmount.type), ) } - private suspend fun estimateFee( + /** + * Estimates gas for several staking transactions. + */ + private suspend fun estimateCompositeGas( + pendingActions: ImmutableList?, + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + onStakingFeeError: (StakingError) -> Unit, + ): StakingGasEstimate? { + val result = coroutineScope { + pendingActions?.map { action -> + async { + // Simultaneous or quick API calls can sometimes return ZERO fee + estimateGasRetry { + estimateSingleGas( + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + action = action, + ) + }.getOrElse { gasError -> + onStakingFeeError(gasError) + null + } + } + }?.awaitAll()?.filterNotNull() + } + + if (result.isNullOrEmpty()) { + onStakingFeeError(StakingError.DomainError("Error estimating fee")) + return null + } + + val totalAmount = result.sumOf { it.amount } + val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() } + return StakingGasEstimate( + amount = totalAmount, + token = result.first().token, + gasLimit = totalGasLimit.toPlainString().orEmpty(), + ) + } + + private suspend fun estimateSingleGas( amount: BigDecimal, sourceAddress: String, validatorAddress: String, @@ -179,7 +218,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ), ) - private suspend fun getApproveFee( + private suspend fun getApprovalFee( amount: BigDecimal, validatorAddress: String, onApprovalFee: (TransactionFee) -> Unit, @@ -187,6 +226,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ) { val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return onApprovalFeeError(GetFeeError.UnknownError) + val approvalTransactionData = createApprovalTransactionUseCase( cryptoCurrencyStatus = cryptoCurrencyStatus, userWalletId = userWallet.walletId, @@ -196,34 +236,29 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( ).getOrElse { return onApprovalFeeError(GetFeeError.DataError(it)) } + getFeeUseCase( userWallet = userWallet, network = tokenCurrency.network, transactionData = approvalTransactionData, ).fold( - ifRight = { fee -> - onApprovalFee(fee) - }, - ifLeft = { error -> - onApprovalFeeError(error) - }, + ifRight = onApprovalFee, + ifLeft = onApprovalFeeError, ) } - private suspend fun estimateFeeRetry( - times: Int = 3, - delay: Long = 1000, + private suspend fun estimateGasRetry( + times: Int = RETRY_COUNT, + delayMs: Long = RETRY_DELAY_MS, block: suspend () -> Either, ): Either { repeat(times - 1) { val feeResult = block() feeResult.fold( ifLeft = { return feeResult }, - ifRight = { - if (!it.amount.isZero()) return feeResult - }, + ifRight = { estimate -> if (!estimate.amount.isZero()) return feeResult }, ) - delay(delay) + delay(delayMs) } return block() } @@ -233,7 +268,12 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( fun create( cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet, - integration: StakingIntegration, - ): StakingFeeTransactionLoader + integration: StakeKitIntegration, + ): StakeKitFeeLoader + } + + private companion object { + const val RETRY_COUNT = 3 + const val RETRY_DELAY_MS = 1000L } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt new file mode 100644 index 0000000000..f1b004afe8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt @@ -0,0 +1,322 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import arrow.core.getOrElse +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionSender +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.SubmitHashData +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.transaction.ActionParams +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount +import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions +import com.tangem.utils.extensions.orZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import timber.log.Timber +import java.math.BigDecimal + +@Suppress("LongParameterList") +internal class StakeKitTransactionSender @AssistedInject constructor( + private val stateController: StakingStateController, + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, + private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val submitHashUseCase: SubmitHashUseCase, + private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, + @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, + @Assisted private val userWallet: UserWallet, + @Assisted private val integration: StakeKitIntegration, + @Assisted private val isAmountSubtractAvailable: Boolean, +) : StakingTransactionSender { + + private val balanceUpdater: StakingBalanceUpdater + get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + + override suspend fun send(callbacks: StakingTransactionSender.Callbacks) { + constructAndSendTransactions( + onConstructSuccess = callbacks.onConstructSuccess, + onConstructError = callbacks.onConstructError, + onSendSuccess = callbacks.onSendSuccess, + onSendError = callbacks.onSendError, + onFeeIncreased = callbacks.onFeeIncreased, + ) + } + + private suspend fun constructAndSendTransactions( + onConstructSuccess: (List) -> Unit, + onConstructError: (StakingError) -> Unit, + onSendSuccess: (String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, + ) { + val state = stateController.value + + val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val amountState = state.amountState as? AmountState.Data ?: error("No amount state") + + val stakingTransactions = getStakingTransactions( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + + val fullTransactionsData = getConstructedTransactions( + stakingTransactions = stakingTransactions, + fee = fee, + amount = amountState.amountTextField.cryptoAmount.value.orZero(), + onConstructError = onConstructError, + ) + + if (fullTransactionsData.isNullOrEmpty()) { + onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) + return + } + + val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } + + if (fee.amount.value.orZero() >= totalFee) { + onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) + sendTransaction( + fullTransactionsData = fullTransactionsData, + onSendSuccess = onSendSuccess, + onSendError = onSendError, + ) + } else { + val amount = fee.amount.copy(value = totalFee) + onFeeIncreased( + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), + ) + } + } + + private suspend fun getStakingTransactions( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + val isComposePendingActions = isCompositePendingActions( + networkId = cryptoCurrencyStatus.currency.network.rawId, + pendingActions = confirmationState.pendingActions, + ) + if (isComposePendingActions) { + confirmationState.pendingActions?.map { action -> + async { + getStakingTransaction( + state = state, + action = action, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + }?.awaitAll()?.flatten() + } else { + getStakingTransaction( + state = state, + confirmationState = confirmationState, + onConstructError = onConstructError, + ) + } + } + + private suspend fun getConstructedTransactions( + stakingTransactions: List?, + fee: Fee, + amount: BigDecimal, + onConstructError: (StakingError) -> Unit, + ) = coroutineScope { + stakingTransactions + ?.filterNot { + it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED + } + ?.map { transaction -> + async { + getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.rawId, + fee = fee, + amount = amount.convertToSdkAmount(cryptoCurrencyStatus), + transactionId = transaction.id, + ).fold( + ifRight = { (constructedTransaction, transactionData) -> + FullTransactionData( + stakeKitTransaction = constructedTransaction, + tangemTransaction = transactionData, + ) + }, + ifLeft = { error -> + onConstructError(error) + null + }, + ) + } + } + ?.awaitAll() + ?.filterNotNull() + } + + private suspend fun getStakingTransaction( + state: StakingUiState, + confirmationState: StakingStates.ConfirmationState.Data, + action: PendingAction? = confirmationState.pendingAction, + onConstructError: (StakingError) -> Unit, + ): List { + val validatorState = state.validatorState as? StakingStates.ValidatorState.Data + ?: error("No validator provided") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee + ?: error("No fee provided") + val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: error("No available address") + val amountState = state.amountState as? AmountState.Data + ?: error("No amount provided") + + val validatorAddress = validatorState.chosenTarget.address + val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) + + return getStakingTransactionsUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = state.actionType, + integrationId = integration.integrationId.value, + amount = amount, + address = defaultAddress, + validatorAddress = validatorAddress, + token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), + passthrough = action?.passthrough, + type = action?.type, + ), + ).getOrElse { error -> + onConstructError(error) + return emptyList() + } + } + + private suspend fun sendTransaction( + fullTransactionsData: List, + onSendSuccess: (txUrl: String) -> Unit, + onSendError: (SendTransactionError) -> Unit, + ) { + if (fullTransactionsData.isEmpty()) return + + val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } + + val firstTransaction = sortedTransactions.first() + val network = firstTransaction.stakeKitTransaction.network + + val sendMode = if (network == NetworkType.SOLANA && + firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT + ) { + TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST + } else { + TransactionSender.MultipleTransactionSendMode.DEFAULT + } + + sendTransactionUseCase( + txsData = sortedTransactions.map { it.tangemTransaction }, + userWallet = userWallet, + network = cryptoCurrencyStatus.currency.network, + sendMode = sendMode, + ).fold( + ifLeft = { error -> + onSendError(error) + }, + ifRight = { transactionHashes -> + submitHash( + transactions = sortedTransactions.map { it.stakeKitTransaction }, + transactionHashes = transactionHashes, + ) + + val txUrl = getExplorerTransactionUrlUseCase( + txHash = transactionHashes.last(), + networkId = cryptoCurrencyStatus.currency.network.id, + ).getOrNull().orEmpty() + + balanceUpdater.updateAfterTransaction() + onSendSuccess(txUrl) + }, + ) + } + + private suspend fun submitHash(transactions: List, transactionHashes: List) { + transactions + .zip(transactionHashes) + .forEach { (transaction, transactionHash) -> + submitHashUseCase( + SubmitHashData( + transactionId = transaction.id, + transactionHash = transactionHash, + ), + ) + .onLeft { + saveUnsubmittedHashUseCase.invoke( + transactionId = transaction.id, + transactionHash = transactionHash, + ) + }.onRight { + Timber.d("Successful hash submission") + } + } + } + + private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val feeValue = fee.amount.value ?: error("No fee value") + val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter + + return checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + } + + private data class FullTransactionData( + val stakeKitTransaction: StakingTransaction, + val tangemTransaction: TransactionData.Compiled, + ) + + @AssistedFactory + interface Factory { + fun create( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakeKitIntegration, + isAmountSubtractAvailable: Boolean, + ): StakeKitTransactionSender + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt new file mode 100644 index 0000000000..f4e8d449bb --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeLoader.kt @@ -0,0 +1,16 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.transaction.error.GetFeeError + +internal interface StakingFeeLoader { + + suspend fun getFee( + onStakingFee: (Fee, Boolean) -> Unit, + onStakingFeeError: (StakingError) -> Unit, + onApprovalFee: (TransactionFee) -> Unit, + onFeeError: (GetFeeError) -> Unit, + ) +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt new file mode 100644 index 0000000000..b1da82c9c5 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingOperationsFactory.kt @@ -0,0 +1,55 @@ +package com.tangem.features.staking.impl.presentation.state.helpers + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.P2PEthPoolIntegration +import com.tangem.domain.staking.model.StakeKitIntegration +import com.tangem.domain.staking.model.StakingIntegration +import javax.inject.Inject + +internal class StakingOperationsFactory @Inject constructor( + private val stakeKitFeeLoaderFactory: StakeKitFeeLoader.Factory, + private val p2pEthPoolFeeLoaderFactory: P2PEthPoolFeeLoader.Factory, + private val stakeKitTransactionSenderFactory: StakeKitTransactionSender.Factory, + private val p2pEthPoolTransactionSenderFactory: P2PEthPoolTransactionSender.Factory, +) { + + fun createFeeLoader( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + ): StakingFeeLoader { + return when (integration) { + is StakeKitIntegration -> stakeKitFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + is P2PEthPoolIntegration -> p2pEthPoolFeeLoaderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + integration = integration, + ) + } + } + + fun createTransactionSender( + cryptoCurrencyStatus: CryptoCurrencyStatus, + userWallet: UserWallet, + integration: StakingIntegration, + isAmountSubtractAvailable: Boolean, + ): StakingTransactionSender { + return when (integration) { + is StakeKitIntegration -> stakeKitTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + isAmountSubtractAvailable = isAmountSubtractAvailable, + ) + is P2PEthPoolIntegration -> p2pEthPoolTransactionSenderFactory.create( + cryptoCurrencyStatus = cryptoCurrencyStatus, + userWallet = userWallet, + integration = integration, + ) + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index f54ed73203..968bf1c310 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -1,312 +1,20 @@ package com.tangem.features.staking.impl.presentation.state.helpers -import arrow.core.getOrElse -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.NetworkType -import com.tangem.domain.models.staking.PendingAction -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase -import com.tangem.domain.staking.GetStakingTransactionsUseCase -import com.tangem.domain.staking.SaveUnsubmittedHashUseCase -import com.tangem.domain.staking.SubmitHashUseCase -import com.tangem.domain.staking.model.StakingIntegration -import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus -import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType import com.tangem.domain.transaction.error.SendTransactionError -import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.domain.transaction.usecase.SendTransactionUseCase -import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.staking.impl.presentation.state.FeeState -import com.tangem.features.staking.impl.presentation.state.StakingStateController -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount -import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions -import com.tangem.utils.extensions.orZero -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import timber.log.Timber -import java.math.BigDecimal -@Suppress("LongParameterList") -internal class StakingTransactionSender @AssistedInject constructor( - private val stateController: StakingStateController, - private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, - private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase, - private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, - private val sendTransactionUseCase: SendTransactionUseCase, - private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val submitHashUseCase: SubmitHashUseCase, - private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, - private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, - @Assisted private val userWallet: UserWallet, - @Assisted private val integration: StakingIntegration, - @Assisted private val isAmountSubtractAvailable: Boolean, -) { +internal interface StakingTransactionSender { - private val balanceUpdater: StakingBalanceUpdater - get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration) + suspend fun send(callbacks: Callbacks) - suspend fun constructAndSendTransactions( - onConstructSuccess: (List) -> Unit, - onConstructError: (StakingError) -> Unit, - onSendSuccess: (String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - onFeeIncreased: (Fee, Boolean) -> Unit, - ) { - val state = stateController.value - - val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data - ?: error("No confirmation state") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val amountState = state.amountState as? AmountState.Data ?: error("No amount state") - - val stakingTransactions = getStakingTransactions( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - - val fullTransactionsData = getConstructedTransactions( - stakingTransactions = stakingTransactions, - fee = fee, - amount = amountState.amountTextField.cryptoAmount.value.orZero(), - onConstructError = onConstructError, - ) - - if (fullTransactionsData.isNullOrEmpty()) { - onConstructError(StakingError.DomainError("fullTransactionsData is null or empty")) - return - } - - val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() } - - if (fee.amount.value.orZero() >= totalFee) { - onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction }) - sendStakingTransaction( - fullTransactionsData = fullTransactionsData, - onSendSuccess = onSendSuccess, - onSendError = onSendError, - ) - } else { - val amount = fee.amount.copy(value = totalFee) - onFeeIncreased( - Fee.Common(amount), - isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), - ) - } - } - - private suspend fun getStakingTransactions( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - val isComposePendingActions = isCompositePendingActions( - networkId = cryptoCurrencyStatus.currency.network.rawId, - pendingActions = confirmationState.pendingActions, - ) - if (isComposePendingActions) { - confirmationState.pendingActions?.map { action -> - async { - getStakingTransaction( - state = state, - action = action, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - }?.awaitAll()?.flatten() - } else { - getStakingTransaction( - state = state, - confirmationState = confirmationState, - onConstructError = onConstructError, - ) - } - } - - private suspend fun getConstructedTransactions( - stakingTransactions: List?, - fee: Fee, - amount: BigDecimal, - onConstructError: (StakingError) -> Unit, - ) = coroutineScope { - stakingTransactions - ?.filterNot { - it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED - } - ?.map { transaction -> - async { - getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.rawId, - fee = fee, - amount = amount.convertToSdkAmount(cryptoCurrencyStatus), - transactionId = transaction.id, - ).fold( - ifRight = { (constructedTransaction, transactionData) -> - FullTransactionData( - stakeKitTransaction = constructedTransaction, - tangemTransaction = transactionData, - ) - }, - ifLeft = { - onConstructError(it) - null - }, - ) - } - } - ?.awaitAll() - ?.filterNotNull() - } - - private suspend fun getStakingTransaction( - state: StakingUiState, - confirmationState: StakingStates.ConfirmationState.Data, - action: PendingAction? = confirmationState.pendingAction, - onConstructError: (StakingError) -> Unit, - ): List { - val validatorState = state.validatorState as? StakingStates.ValidatorState.Data - ?: error("No validator provided") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee - ?: error("No fee provided") - val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value - ?: error("No available address") - val amountState = state.amountState as? AmountState.Data - ?: error("No amount provided") - - val validatorAddress = validatorState.chosenTarget.address - val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy) - - return getStakingTransactionsUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrencyStatus.currency.network, - params = ActionParams( - actionCommonType = state.actionType, - integrationId = integration.integrationId.value, - amount = amount, - address = defaultAddress, - validatorAddress = validatorAddress, - token = integration.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId), - passthrough = action?.passthrough, - type = action?.type, - ), - ).getOrElse { - onConstructError(it) - return emptyList() - } - } - - private suspend fun sendStakingTransaction( - fullTransactionsData: List, - onSendSuccess: (txUrl: String) -> Unit, - onSendError: (SendTransactionError) -> Unit, - ) { - if (fullTransactionsData.isEmpty()) return - - val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex } - - val firstTransaction = sortedTransactions.first() - val network = firstTransaction.stakeKitTransaction.network - - val sendMode = if (network == NetworkType.SOLANA && - firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT - ) { - TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST - } else { - TransactionSender.MultipleTransactionSendMode.DEFAULT - } - - sendTransactionUseCase( - txsData = sortedTransactions.map { it.tangemTransaction }, - userWallet = userWallet, - network = cryptoCurrencyStatus.currency.network, - sendMode = sendMode, - ).fold( - ifLeft = { error -> - onSendError(error) - }, - ifRight = { transactionHashes -> - submitHash( - transactions = sortedTransactions.map { it.stakeKitTransaction }, - transactionHashes = transactionHashes, - ) - - val txUrl = getExplorerTransactionUrlUseCase( - txHash = transactionHashes.last(), - networkId = cryptoCurrencyStatus.currency.network.id, - ).getOrNull() ?: "" - - balanceUpdater.updateAfterTransaction() - onSendSuccess(txUrl) - }, - ) - } - - private suspend fun submitHash(transactions: List, transactionHashes: List) { - transactions - .zip(transactionHashes) - .forEach { (transaction, transactionHash) -> - submitHashUseCase( - SubmitHashData( - transactionId = transaction.id, - transactionHash = transactionHash, - ), - ) - .onLeft { - saveUnsubmittedHashUseCase.invoke( - transactionId = transaction.id, - transactionHash = transactionHash, - ) - }.onRight { - Timber.d("Successful hash submission") - } - } - } - - private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal { - val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") - val feeValue = fee.amount.value ?: error("No fee value") - val isEnterAction = stateController.value.actionType is StakingActionCommonType.Enter - - return checkAndCalculateSubtractedAmount( - isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction, - cryptoCurrencyStatus = cryptoCurrencyStatus, - amountValue = amountValue, - feeValue = feeValue, - reduceAmountBy = reduceAmountBy.orZero(), - ) - } - - private data class FullTransactionData( - val stakeKitTransaction: StakingTransaction, - val tangemTransaction: TransactionData.Compiled, + class Callbacks( + val onConstructSuccess: (List) -> Unit, + val onConstructError: (StakingError) -> Unit, + val onSendSuccess: (String) -> Unit, + val onSendError: (SendTransactionError) -> Unit, + val onFeeIncreased: (Fee, Boolean) -> Unit, + val onTransactionExpired: () -> Unit, ) - - @AssistedFactory - interface Factory { - fun create( - cryptoCurrencyStatus: CryptoCurrencyStatus, - userWallet: UserWallet, - integration: StakingIntegration, - isAmountSubtractAvailable: Boolean, - ): StakingTransactionSender - } } \ No newline at end of file