Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-23 14:55:04 +02:00
parent c3c5874060
commit 016b1e18d2
13 changed files with 858 additions and 421 deletions

View file

@ -248,7 +248,7 @@ internal class DefaultP2PEthPoolRepository(
} }
} }
private suspend fun getVaultsSync(): List<P2PEthPoolVault> { override suspend fun getVaultsSync(): List<P2PEthPoolVault> {
return p2pEthPoolVaultsStore.getSync() return p2pEthPoolVaultsStore.getSync()
} }
} }

View file

@ -11,7 +11,7 @@ import java.math.BigDecimal
* Strategy interface for staking integrations. * Strategy interface for staking integrations.
* Abstracts over StakeKit and P2PEthPool staking providers. * Abstracts over StakeKit and P2PEthPool staking providers.
*/ */
interface StakingIntegration { sealed interface StakingIntegration {
// Basic // Basic

View file

@ -132,6 +132,16 @@ interface P2PEthPoolRepository {
*/ */
fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> fun getVaultsFlow(): Flow<List<P2PEthPoolVault>>
/**
* 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<P2PEthPoolVault>
/** /**
* Check P2PEthPool staking availability by finding public vault * Check P2PEthPool staking availability by finding public vault
* *

View file

@ -24,6 +24,7 @@ dependencies {
implementation(deps.lifecycle.compose) implementation(deps.lifecycle.compose)
implementation(deps.jodatime) implementation(deps.jodatime)
implementation(deps.timber) implementation(deps.timber)
implementation(deps.moshi)
/** Compose */ /** Compose */
implementation(deps.compose.accompanist.systemUiController) implementation(deps.compose.accompanist.systemUiController)

View file

@ -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.StakingEvent
import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory 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.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.helpers.StakingTransactionSender
import com.tangem.features.staking.impl.presentation.state.transformers.* import com.tangem.features.staking.impl.presentation.state.transformers.*
import com.tangem.features.staking.impl.presentation.state.transformers.amount.* 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 isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase, private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase,
private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase, private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase,
private val stakingTransactionLoader: StakingTransactionSender.Factory, private val stakingOperationsFactory: StakingOperationsFactory,
private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory,
private val stakingBalanceUpdater: StakingBalanceUpdater.Factory, private val stakingBalanceUpdater: StakingBalanceUpdater.Factory,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
@ -175,8 +175,7 @@ internal class StakingModel @Inject constructor(
StakeKitIntegration(integrationId, yield) StakeKitIntegration(integrationId, yield)
} }
StakingIntegrationID.P2PEthPool -> { StakingIntegrationID.P2PEthPool -> {
// TODO p2p avoid network call val vaults = p2pEthPoolRepository.getVaultsSync()
val vaults = p2pEthPoolRepository.getVaults().getOrElse { emptyList() }
P2PEthPoolIntegration(integrationId, vaults) P2PEthPoolIntegration(integrationId, vaults)
} }
} }
@ -221,16 +220,16 @@ internal class StakingModel @Inject constructor(
) )
} }
private val feeLoader by lazy(LazyThreadSafetyMode.NONE) { private val feeLoader: StakingFeeLoader by lazy(LazyThreadSafetyMode.NONE) {
stakingFeeTransactionLoader.create( stakingOperationsFactory.createFeeLoader(
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
userWallet = userWallet, userWallet = userWallet,
integration = integration, integration = integration,
) )
} }
private val transactionSender by lazy(LazyThreadSafetyMode.NONE) { private val transactionSender: StakingTransactionSender by lazy(LazyThreadSafetyMode.NONE) {
stakingTransactionLoader.create( stakingOperationsFactory.createTransactionSender(
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
userWallet = userWallet, userWallet = userWallet,
integration = integration, integration = integration,
@ -391,49 +390,57 @@ internal class StakingModel @Inject constructor(
modelScope.launch { modelScope.launch {
stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value)
stateController.update(SetConfirmationStateInProgressTransformer()) stateController.update(SetConfirmationStateInProgressTransformer())
transactionSender.constructAndSendTransactions( transactionSender.send(
onConstructSuccess = { constructedTransactions -> StakingTransactionSender.Callbacks(
transactionsInProgress.addAll(constructedTransactions) onConstructSuccess = { constructedTransactions ->
}, transactionsInProgress.addAll(constructedTransactions)
onConstructError = { error -> },
stakingEventFactory.createStakingErrorAlert(error) onConstructError = { error ->
stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) stakingEventFactory.createStakingErrorAlert(error)
}, stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus))
onSendSuccess = { txUrl -> },
stakingAnalyticSender.sendTransactionStakingAnalytics( onSendSuccess = { txUrl ->
stateController.value, stakingAnalyticSender.sendTransactionStakingAnalytics(
cryptoCurrencyStatus, stateController.value,
) cryptoCurrencyStatus,
transactionsInProgress.clear() )
stateController.update(SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus)) transactionsInProgress.clear()
}, stateController.update(
onSendError = { error -> SetConfirmationStateCompletedTransformer(txUrl, cryptoCurrencyStatus),
analyticsEventHandler.send( )
StakingAnalyticsEvent.TransactionError( },
errorCode = error.getAnalyticsDescription(), onSendError = { error ->
), analyticsEventHandler.send(
) StakingAnalyticsEvent.TransactionError(
stakingEventFactory.createSendTransactionErrorAlert(error) errorCode = error.getAnalyticsDescription(),
stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) ),
}, )
onFeeIncreased = { increasedFee, isFeeApproximate -> stakingEventFactory.createSendTransactionErrorAlert(error)
stateController.updateAll( stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus))
SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), },
SetConfirmationStateAssentTransformer( onFeeIncreased = { increasedFee, isFeeApproximate ->
appCurrencyProvider = Provider { appCurrency }, stateController.updateAll(
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus),
fee = increasedFee, SetConfirmationStateAssentTransformer(
isFeeApproximate = isFeeApproximate, appCurrencyProvider = Provider { appCurrency },
cryptoCurrencyStatus = cryptoCurrencyStatus, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
), fee = increasedFee,
) isFeeApproximate = isFeeApproximate,
stateController.updateEvent( cryptoCurrencyStatus = cryptoCurrencyStatus,
StakingEvent.ShowAlert( ),
StakingAlertUM.FeeIncreased(stateController::dismissAlert), )
), stateController.updateEvent(
) StakingEvent.ShowAlert(
updateNotifications() StakingAlertUM.FeeIncreased(stateController::dismissAlert),
}, ),
)
updateNotifications()
},
onTransactionExpired = {
stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus))
getFee()
},
),
) )
}.saveIn(sendTransactionJobHolder) }.saveIn(sendTransactionJobHolder)
} }

View file

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

View file

@ -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<StakingError, P2PEthPoolUnsignedTx> {
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<StakingError, P2PEthPoolUnsignedTx> {
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,
)
}

View file

@ -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<EthereumCompiledTransaction> =
Moshi.Builder().build().adapter(EthereumCompiledTransaction::class.java)
}
}

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.PendingAction import com.tangem.domain.models.staking.PendingAction
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.EstimateGasUseCase 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.StakingError
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType 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.ActionParams
@ -36,7 +36,7 @@ import kotlinx.coroutines.delay
import java.math.BigDecimal import java.math.BigDecimal
@Suppress("LongParameterList") @Suppress("LongParameterList")
internal class StakingFeeTransactionLoader @AssistedInject constructor( internal class StakeKitFeeLoader @AssistedInject constructor(
private val stateController: StakingStateController, private val stateController: StakingStateController,
private val getFeeUseCase: GetFeeUseCase, private val getFeeUseCase: GetFeeUseCase,
private val estimateGasUseCase: EstimateGasUseCase, private val estimateGasUseCase: EstimateGasUseCase,
@ -44,10 +44,10 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
@Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus,
@Assisted private val userWallet: UserWallet, @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, onStakingFee: (Fee, Boolean) -> Unit,
onStakingFeeError: (StakingError) -> Unit, onStakingFeeError: (StakingError) -> Unit,
onApprovalFee: (TransactionFee) -> Unit, onApprovalFee: (TransactionFee) -> Unit,
@ -57,21 +57,41 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data
?: error("Illegal state") ?: 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 ?: state.balanceState?.targetAddress
?: error("No target address provided") ?: error("No target address provided")
val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
?: error("No amount provided") ?: error("No amount provided")
val pendingAction = confirmationState.pendingAction getStakeKitFee(
val pendingActions = confirmationState.pendingActions 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 isApprovalNeeded = confirmationState.isApprovalNeeded
val isAllowanceNotEnough = confirmationState.allowance < amount val isAllowanceNotEnough = confirmationState.allowance < amount
if (isEnter && isApprovalNeeded && isAllowanceNotEnough) { if (isEnter && isApprovalNeeded && isAllowanceNotEnough) {
getApproveFee( getApprovalFee(
amount = amount, amount = amount,
validatorAddress = validatorAddress, validatorAddress = validatorAddress,
onApprovalFee = onApprovalFee, onApprovalFee = onApprovalFee,
@ -79,8 +99,8 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
) )
} else { } else {
estimateGas( estimateGas(
pendingAction = pendingAction, pendingAction = confirmationState.pendingAction,
pendingActions = pendingActions, pendingActions = confirmationState.pendingActions,
amount = amount, amount = amount,
validatorAddress = validatorAddress, validatorAddress = validatorAddress,
onStakingFeeError = onStakingFeeError, onStakingFeeError = onStakingFeeError,
@ -105,61 +125,80 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
pendingActions = pendingActions, pendingActions = pendingActions,
) )
) { ) {
val result = coroutineScope { estimateCompositeGas(
pendingActions?.map { action -> pendingActions = pendingActions,
async { amount = amount,
// Simultaneous or quick api calls can sometimes return ZERO fee sourceAddress = sourceAddress,
estimateFeeRetry { validatorAddress = validatorAddress,
estimateFee( onStakingFeeError = onStakingFeeError,
amount = amount, ) ?: return
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(),
)
} else { } else {
estimateFee( estimateSingleGas(
amount = amount, amount = amount,
sourceAddress = sourceAddress, sourceAddress = sourceAddress,
validatorAddress = validatorAddress, validatorAddress = validatorAddress,
action = pendingAction, action = pendingAction,
).getOrElse { ).getOrElse { error ->
onStakingFeeError(it) onStakingFeeError(error)
return return
} }
} }
val amount = Amount( val feeAmount = Amount(
currencySymbol = gasEstimate.token.symbol, currencySymbol = gasEstimate.token.symbol,
value = gasEstimate.amount, value = gasEstimate.amount,
decimals = gasEstimate.token.decimals, decimals = gasEstimate.token.decimals,
) )
onStakingFee( onStakingFee(
Fee.Common(amount), Fee.Common(feeAmount),
isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), 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<PendingAction>?,
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, amount: BigDecimal,
sourceAddress: String, sourceAddress: String,
validatorAddress: String, validatorAddress: String,
@ -179,7 +218,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
), ),
) )
private suspend fun getApproveFee( private suspend fun getApprovalFee(
amount: BigDecimal, amount: BigDecimal,
validatorAddress: String, validatorAddress: String,
onApprovalFee: (TransactionFee) -> Unit, onApprovalFee: (TransactionFee) -> Unit,
@ -187,6 +226,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
) { ) {
val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
?: return onApprovalFeeError(GetFeeError.UnknownError) ?: return onApprovalFeeError(GetFeeError.UnknownError)
val approvalTransactionData = createApprovalTransactionUseCase( val approvalTransactionData = createApprovalTransactionUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
@ -196,34 +236,29 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
).getOrElse { ).getOrElse {
return onApprovalFeeError(GetFeeError.DataError(it)) return onApprovalFeeError(GetFeeError.DataError(it))
} }
getFeeUseCase( getFeeUseCase(
userWallet = userWallet, userWallet = userWallet,
network = tokenCurrency.network, network = tokenCurrency.network,
transactionData = approvalTransactionData, transactionData = approvalTransactionData,
).fold( ).fold(
ifRight = { fee -> ifRight = onApprovalFee,
onApprovalFee(fee) ifLeft = onApprovalFeeError,
},
ifLeft = { error ->
onApprovalFeeError(error)
},
) )
} }
private suspend fun estimateFeeRetry( private suspend fun estimateGasRetry(
times: Int = 3, times: Int = RETRY_COUNT,
delay: Long = 1000, delayMs: Long = RETRY_DELAY_MS,
block: suspend () -> Either<StakingError, StakingGasEstimate>, block: suspend () -> Either<StakingError, StakingGasEstimate>,
): Either<StakingError, StakingGasEstimate> { ): Either<StakingError, StakingGasEstimate> {
repeat(times - 1) { repeat(times - 1) {
val feeResult = block() val feeResult = block()
feeResult.fold( feeResult.fold(
ifLeft = { return feeResult }, ifLeft = { return feeResult },
ifRight = { ifRight = { estimate -> if (!estimate.amount.isZero()) return feeResult },
if (!it.amount.isZero()) return feeResult
},
) )
delay(delay) delay(delayMs)
} }
return block() return block()
} }
@ -233,7 +268,12 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor(
fun create( fun create(
cryptoCurrencyStatus: CryptoCurrencyStatus, cryptoCurrencyStatus: CryptoCurrencyStatus,
userWallet: UserWallet, userWallet: UserWallet,
integration: StakingIntegration, integration: StakeKitIntegration,
): StakingFeeTransactionLoader ): StakeKitFeeLoader
}
private companion object {
const val RETRY_COUNT = 3
const val RETRY_DELAY_MS = 1000L
} }
} }

View file

@ -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<StakingTransaction>) -> 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<StakingTransaction>?,
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<StakingTransaction> {
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<FullTransactionData>,
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<StakingTransaction>, transactionHashes: List<String>) {
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
}
}

View file

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

View file

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

View file

@ -1,312 +1,20 @@
package com.tangem.features.staking.impl.presentation.state.helpers 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.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.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.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.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 interface StakingTransactionSender {
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,
) {
private val balanceUpdater: StakingBalanceUpdater suspend fun send(callbacks: Callbacks)
get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, integration)
suspend fun constructAndSendTransactions( class Callbacks(
onConstructSuccess: (List<StakingTransaction>) -> Unit, val onConstructSuccess: (List<StakingTransaction>) -> Unit,
onConstructError: (StakingError) -> Unit, val onConstructError: (StakingError) -> Unit,
onSendSuccess: (String) -> Unit, val onSendSuccess: (String) -> Unit,
onSendError: (SendTransactionError) -> Unit, val onSendError: (SendTransactionError) -> Unit,
onFeeIncreased: (Fee, Boolean) -> Unit, val onFeeIncreased: (Fee, Boolean) -> Unit,
) { val onTransactionExpired: () -> 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<StakingTransaction>?,
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<StakingTransaction> {
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<FullTransactionData>,
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<StakingTransaction>, transactionHashes: List<String>) {
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: StakingIntegration,
isAmountSubtractAvailable: Boolean,
): StakingTransactionSender
}
} }