Updated on 2026-08-14
This commit is contained in:
parent
1cf8a16fae
commit
24d7953389
30 changed files with 3096 additions and 494 deletions
|
|
@ -16,6 +16,14 @@ internal class DefaultFeeErrorResolver : FeeErrorResolver {
|
|||
is BlockchainSdkError.Sui.OneSuiRequired -> {
|
||||
GetFeeError.BlockchainErrors.SuiOneCoinRequired
|
||||
}
|
||||
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
|
||||
GetFeeError.EstimateOverrideError(
|
||||
blockchain = throwable.blockchain,
|
||||
tokenSymbol = throwable.tokenSymbol,
|
||||
rpcProvider = throwable.rpcProvider,
|
||||
error = throwable.underlyingError,
|
||||
)
|
||||
}
|
||||
else -> GetFeeError.DataError(throwable)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
package com.tangem.data.transaction.error
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.BlockchainSdkError
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [DefaultFeeErrorResolver] — the [Throwable] -> [GetFeeError] resolver. Mirrors the
|
||||
* mapping matrix of `ErrorsMapper.mapToFeeError` but driven through `resolve(throwable)`.
|
||||
*
|
||||
* Focuses on the [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be
|
||||
* resolved to [GetFeeError.EstimateOverrideError] field-by-field; representative other chains map
|
||||
* to their dedicated [GetFeeError.BlockchainErrors]; everything else falls through to
|
||||
* [GetFeeError.DataError].
|
||||
*/
|
||||
internal class DefaultFeeErrorResolverTest {
|
||||
|
||||
private val resolver = DefaultFeeErrorResolver()
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError THEN resolves to GetFeeError EstimateOverrideError field by field`() {
|
||||
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
underlyingError = "execution reverted",
|
||||
)
|
||||
|
||||
val result = resolver.resolve(sdkError)
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
|
||||
val mapped = result as GetFeeError.EstimateOverrideError
|
||||
assertThat(mapped.blockchain).isEqualTo("ethereum")
|
||||
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
|
||||
assertThat(mapped.rpcProvider).isEqualTo("infura")
|
||||
assertThat(mapped.error).isEqualTo("execution reverted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TronActivationError THEN resolves to TronActivationError`() {
|
||||
// AccountActivationError is a class taking an int code, not an object.
|
||||
val result = resolver.resolve(BlockchainSdkError.Tron.AccountActivationError(code = 0))
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN KaspaZeroUtxoError THEN resolves to KaspaZeroUtxo`() {
|
||||
val result = resolver.resolve(BlockchainSdkError.Kaspa.ZeroUtxoError)
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN SuiOneSuiRequired THEN resolves to SuiOneCoinRequired`() {
|
||||
val result = resolver.resolve(BlockchainSdkError.Sui.OneSuiRequired)
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown error THEN resolves to DataError preserving the throwable`() {
|
||||
val sdkError = BlockchainSdkError.CustomError("boom")
|
||||
|
||||
val result = resolver.resolve(sdkError)
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ sealed class GetFeeError {
|
|||
data object TronActivationError : BlockchainErrors()
|
||||
data object KaspaZeroUtxo : BlockchainErrors()
|
||||
data object SuiOneCoinRequired : BlockchainErrors()
|
||||
data object TooLargeSolanaTransactionError : BlockchainErrors()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,4 +20,15 @@ sealed class GetFeeError {
|
|||
data object NotEnoughFunds : GaslessError()
|
||||
data class DataError(val cause: Throwable?) : GaslessError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for gas estimation with state override for ethereum like networks.
|
||||
* Specifically overriding approval slot.
|
||||
*/
|
||||
data class EstimateOverrideError(
|
||||
val blockchain: String,
|
||||
val tokenSymbol: String,
|
||||
val rpcProvider: String,
|
||||
val error: String,
|
||||
) : GetFeeError()
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_C
|
|||
import com.tangem.sdk.extensions.localizedDescriptionRes
|
||||
|
||||
fun Result.Failure.mapToFeeError(): GetFeeError {
|
||||
return when (this.error) {
|
||||
return when (val gasError = error) {
|
||||
is BlockchainSdkError.Tron.AccountActivationError -> {
|
||||
GetFeeError.BlockchainErrors.TronActivationError
|
||||
}
|
||||
|
|
@ -19,7 +19,15 @@ fun Result.Failure.mapToFeeError(): GetFeeError {
|
|||
is BlockchainSdkError.Sui.OneSuiRequired -> {
|
||||
GetFeeError.BlockchainErrors.SuiOneCoinRequired
|
||||
}
|
||||
else -> GetFeeError.DataError(this.error)
|
||||
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
|
||||
GetFeeError.EstimateOverrideError(
|
||||
blockchain = gasError.blockchain,
|
||||
tokenSymbol = gasError.tokenSymbol,
|
||||
rpcProvider = gasError.rpcProvider,
|
||||
error = gasError.underlyingError,
|
||||
)
|
||||
}
|
||||
else -> GetFeeError.DataError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase
|
|||
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.Token
|
||||
|
|
@ -27,7 +28,13 @@ class GetFeeUseCase(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
) {
|
||||
suspend operator fun invoke(userWallet: UserWallet, network: Network, transactionData: TransactionData) = either {
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
transactionData: TransactionData,
|
||||
spenderAddress: String? = null,
|
||||
isSimulateEstimation: Boolean = false,
|
||||
) = either {
|
||||
catch(
|
||||
block = {
|
||||
val transactionSender = if (userWallet is UserWallet.Cold &&
|
||||
|
|
@ -40,8 +47,17 @@ class GetFeeUseCase(
|
|||
network = network,
|
||||
)
|
||||
}
|
||||
val result = transactionSender?.getFee(transactionData = transactionData)
|
||||
?: error("Fee is null")
|
||||
val isEthereumWalletManager = transactionSender is EthereumWalletManager
|
||||
val result = if (isSimulateEstimation && spenderAddress != null && isEthereumWalletManager) {
|
||||
transactionSender.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = spenderAddress,
|
||||
isSimulate = true,
|
||||
)
|
||||
} else {
|
||||
transactionSender?.getFee(transactionData = transactionData)
|
||||
?: error("Fee is null")
|
||||
}
|
||||
|
||||
val maybeFee = when (result) {
|
||||
is Result.Success -> result.data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.tangem.domain.transaction.error
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.BlockchainSdkError
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for [mapToFeeError] — the [Result.Failure] -> [GetFeeError] mapper. Focuses on the
|
||||
* [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be mapped to
|
||||
* [GetFeeError.EstimateOverrideError] field-by-field; all other errors fall through to
|
||||
* [GetFeeError.DataError].
|
||||
*/
|
||||
internal class ErrorsMapperTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError THEN maps to GetFeeError EstimateOverrideError field by field`() {
|
||||
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
underlyingError = "execution reverted",
|
||||
)
|
||||
|
||||
val result = Result.Failure(sdkError).mapToFeeError()
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
|
||||
val mapped = result as GetFeeError.EstimateOverrideError
|
||||
assertThat(mapped.blockchain).isEqualTo("ethereum")
|
||||
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
|
||||
assertThat(mapped.rpcProvider).isEqualTo("infura")
|
||||
assertThat(mapped.error).isEqualTo("execution reverted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TronActivationError THEN maps to TronActivationError`() {
|
||||
// AccountActivationError is a class taking an int code, not an object.
|
||||
val result = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 0)).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN KaspaZeroUtxoError THEN maps to KaspaZeroUtxo`() {
|
||||
val result = Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN SuiOneSuiRequired THEN maps to SuiOneCoinRequired`() {
|
||||
val result = Result.Failure(BlockchainSdkError.Sui.OneSuiRequired).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown error THEN maps to DataError`() {
|
||||
val sdkError = BlockchainSdkError.CustomError("boom")
|
||||
|
||||
val result = Result.Failure(sdkError).mapToFeeError()
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.tangem.domain.transaction.usecase
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for [GetFeeUseCase] — the compiled-transaction overload that decides between the new
|
||||
* simulated `estimateFee` path and the legacy `getFee` path.
|
||||
*
|
||||
* The simulated estimation is selected only when ALL of these hold:
|
||||
* - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true`
|
||||
* - `spenderAddress != null`
|
||||
* - the resolved transaction sender is an [EthereumWalletManager]
|
||||
*
|
||||
* Any other combination falls back to the legacy `getFee(transactionData)`.
|
||||
*/
|
||||
internal class GetFeeUseCaseTest {
|
||||
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val demoConfig: DemoConfig = mockk()
|
||||
|
||||
private val useCase = GetFeeUseCase(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
private val network: Network = mockk(relaxed = true)
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val userWallet: UserWallet = mockk<UserWallet.Hot>(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
private val transactionData: TransactionData = mockk(relaxed = true)
|
||||
private val expectedFee: TransactionFee = mockk(relaxed = true)
|
||||
|
||||
private val ethereumWalletManager: EthereumWalletManager = mockk()
|
||||
private val plainWalletManager: WalletManager = mockk()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { demoConfig.isDemoCardId(any()) } returns false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN simulate + spender + ethereum manager THEN estimateFee is used`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulate = true,
|
||||
)
|
||||
} returns Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulate = true,
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN simulate false THEN legacy getFee is used even for ethereum manager`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = false,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
coVerify(exactly = 0) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulate = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null spender THEN legacy getFee is used even when simulate true`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = null,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
coVerify(exactly = 0) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulate = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-ethereum manager THEN legacy getFee is used even when simulate plus spender`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns plainWalletManager
|
||||
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { plainWalletManager.getFee(transactionData = transactionData) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN getFee returns failure THEN error is mapped to GetFeeError`() = runTest {
|
||||
val failure = Result.Failure(com.tangem.blockchain.common.BlockchainSdkError.CustomError("boom"))
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns plainWalletManager
|
||||
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns failure
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet manager is null THEN DataError is raised`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns null
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = null,
|
||||
isSimulateEstimation = false,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressOperationType
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
|
|
@ -8,6 +9,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus
|
|||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
|
||||
|
|
@ -65,6 +67,7 @@ interface SwapInteractor {
|
|||
fee: SwapFee?,
|
||||
expressOperationType: ExpressOperationType,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
integratedApproval: IntegratedApprovalData? = null,
|
||||
): SwapTransactionState
|
||||
|
||||
/**
|
||||
|
|
@ -123,7 +126,7 @@ interface SwapInteractor {
|
|||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun loadSwapFee(
|
||||
provider: SwapProvider,
|
||||
quotesLoadedState: SwapState.QuotesLoadedState,
|
||||
fromStatus: SwapCurrencyStatus,
|
||||
toStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
|
|
@ -131,4 +134,26 @@ interface SwapInteractor {
|
|||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
isGasless: Boolean,
|
||||
): Either<GetFeeError, SwapFee>
|
||||
|
||||
fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String)
|
||||
|
||||
/**
|
||||
* Builds the on-chain ERC-20 approval transaction for [fromStatus] / [spenderAddress]
|
||||
* with an amount derived from [approveType] (null for `UNLIMITED`, the swap amount for
|
||||
* `LIMITED`), loads its [com.tangem.blockchain.common.transaction.TransactionFee] and returns
|
||||
* both as [IntegratedApprovalData].
|
||||
*
|
||||
* Used by the integrated approve+swap flow when
|
||||
* `SwapFeatureToggles.isSwapIntegratedApproveEnabled` is ON and the quote requires an
|
||||
* allowance bump.
|
||||
*
|
||||
* @param approvalAmount LIMITED-mode swap amount (the user-input amount). Used when
|
||||
* [approveType] is `LIMITED`; ignored for `UNLIMITED`.
|
||||
*/
|
||||
suspend fun loadIntegratedApprovalData(
|
||||
fromStatus: SwapCurrencyStatus,
|
||||
spenderAddress: String,
|
||||
approveType: ApproveType,
|
||||
approvalAmount: BigDecimal,
|
||||
): Either<GetFeeError, IntegratedApprovalData>
|
||||
}
|
||||
|
|
@ -7,11 +7,9 @@ import arrow.core.left
|
|||
import arrow.core.raise.either
|
||||
import arrow.core.right
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.TransactionExtras
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
|
|
@ -34,6 +32,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
|
|
@ -73,6 +72,8 @@ import kotlinx.coroutines.awaitAll
|
|||
import kotlinx.coroutines.supervisorScope
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Collections.newSetFromMap
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Suppress("LargeClass", "LongParameterList")
|
||||
internal class SwapInteractorImpl @Inject constructor(
|
||||
|
|
@ -82,6 +83,8 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
private val sendTransactionUseCase: SendTransactionUseCase,
|
||||
private val createTransactionUseCase: CreateTransactionUseCase,
|
||||
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
|
||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
|
||||
private val getFeeUseCase: GetFeeUseCase,
|
||||
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
|
||||
private val isDemoCardUseCase: IsDemoCardUseCase,
|
||||
private val quotesRepository: QuotesRepository,
|
||||
|
|
@ -113,6 +116,22 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
|
||||
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
|
||||
|
||||
/**
|
||||
* Set of integrated-approve contexts for which the simulated swap-fee estimation
|
||||
* failed with [GetFeeError.EstimateOverrideError]. Once a context is recorded here, the
|
||||
* integrated path is abandoned for the remainder of the session: the permission state is
|
||||
* derived as [PermissionDataState.PermissionRequired] (legacy separate-approval flow).
|
||||
* This survives the periodic quote-refresh task so the failing simulated estimation is not retried every cycle.
|
||||
*/
|
||||
private val integratedApprovalFallbackContexts = newSetFromMap(
|
||||
ConcurrentHashMap<IntegratedApprovalFallbackKey, Boolean>(),
|
||||
)
|
||||
|
||||
private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) =
|
||||
integratedApprovalFallbackContexts.contains(
|
||||
IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress),
|
||||
)
|
||||
|
||||
override suspend fun getPair(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -330,7 +349,9 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
|
||||
val isIntegratedApproveActive = swapFeatureToggles.isSwapIntegratedApproveEnabled
|
||||
val isIntegratedApproveActive = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
|
||||
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
|
||||
|
||||
val isAllowanceSatisfied = if (isIntegratedApproveActive) {
|
||||
allowanceInfo !is AllowanceInfo.ResetNeeded
|
||||
} else {
|
||||
|
|
@ -591,7 +612,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
return result
|
||||
}
|
||||
|
||||
@Suppress("NullableToStringCall")
|
||||
@Suppress("NullableToStringCall", "LongParameterList")
|
||||
override suspend fun onSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -602,6 +623,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
fee: SwapFee?,
|
||||
expressOperationType: ExpressOperationType,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
integratedApproval: IntegratedApprovalData?,
|
||||
): SwapTransactionState {
|
||||
TangemLogger.i(
|
||||
"""
|
||||
|
|
@ -663,12 +685,14 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapFee = fee,
|
||||
amountToSwap = amountToSwap,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun onSwapDex(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -676,6 +700,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
swapData: SwapDataModel,
|
||||
amountToSwap: String,
|
||||
swapFee: SwapFee,
|
||||
integratedApproval: IntegratedApprovalData?,
|
||||
): SwapTransactionState {
|
||||
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
|
||||
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
|
||||
|
|
@ -685,8 +710,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
val fromCurrency = fromSwapCurrencyStatus.currency
|
||||
|
||||
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
|
||||
val spenderAddress = dexTransaction.allowanceContract
|
||||
?: return SwapTransactionState.Error.UnknownError
|
||||
val spenderAddress = dexTransaction.allowanceContract ?: return SwapTransactionState.Error.UnknownError
|
||||
createYieldSwapDexTransaction(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
swapData = swapData,
|
||||
|
|
@ -726,17 +750,95 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
swapData.transaction.txTo
|
||||
}
|
||||
|
||||
return handleSwapResult(
|
||||
return if (integratedApproval != null) {
|
||||
// TODO YIELD payInAddress [REDACTED_TASK_KEY]
|
||||
sendIntegratedApproveAndSwap(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
swapTxData = txData,
|
||||
swapFee = swapFee,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
} else {
|
||||
handleSwapResult(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
txData = txData,
|
||||
payInAddress = payInAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrated approve+swap submission. Selects the approval-fee bucket matching
|
||||
* the user's swap-fee selection, attaches it to the prepared approval tx, and sends both
|
||||
* transactions in a single [TransactionSender.MultipleTransactionSendMode.DEFAULT] batch.
|
||||
*
|
||||
* The success path is identical to the standalone swap path — only the approval-side hash
|
||||
* is dropped (the swap tx hash is what surfaces as the transaction result).
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun sendIntegratedApproveAndSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
provider: SwapProvider,
|
||||
swapData: SwapDataModel,
|
||||
amount: SwapAmount,
|
||||
swapTxData: TransactionData.Uncompiled,
|
||||
swapFee: SwapFee,
|
||||
integratedApproval: IntegratedApprovalData,
|
||||
): SwapTransactionState {
|
||||
val approvalFee = selectFeeForBucket(integratedApproval.approvalFee, swapFee.feeBucket)
|
||||
val approvalTx = integratedApproval.approvalTransaction.copy(fee = approvalFee)
|
||||
|
||||
val sendResult = sendTransactionUseCase(
|
||||
txsData = listOf(approvalTx, swapTxData),
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
|
||||
).fold(
|
||||
ifLeft = { error -> return SwapTransactionState.Error.TransactionError(error) },
|
||||
ifRight = { hashes -> hashes },
|
||||
)
|
||||
|
||||
// The swap tx is the second (and last) hash; the approval hash is intentionally dropped.
|
||||
val swapTxHash = sendResult.lastOrNull() ?: return SwapTransactionState.Error.UnknownError
|
||||
|
||||
return finalizeDexSwapSuccess(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
txData = txData,
|
||||
payInAddress = payInAddress,
|
||||
txHash = swapTxHash,
|
||||
payInAddress = getPayoutAddress(swapTxData),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — selects the approval [Fee] matching the user-picked [FeeBucket] tier. Mirrors
|
||||
* the bucket-to-field mapping used by `GiveApprovalModel.sendApprovalTransaction`:
|
||||
* - `SLOW` → `Choosable.minimum` (fallback `Single.normal`)
|
||||
* - `FAST` → `Choosable.priority` (fallback `Single.normal`)
|
||||
* - all other buckets → `normal`
|
||||
*/
|
||||
private fun selectFeeForBucket(transactionFee: TransactionFee, bucket: FeeBucket): Fee {
|
||||
return when (transactionFee) {
|
||||
is TransactionFee.Choosable -> when (bucket) {
|
||||
FeeBucket.SLOW -> transactionFee.minimum
|
||||
FeeBucket.FAST -> transactionFee.priority
|
||||
else -> transactionFee.normal
|
||||
}
|
||||
is TransactionFee.Single -> transactionFee.normal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch selection:
|
||||
* - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token`
|
||||
|
|
@ -937,46 +1039,72 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
return result.fold(
|
||||
ifRight = { txHash ->
|
||||
val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress
|
||||
val fromAddress = networkAddress?.defaultAddress?.value.orEmpty()
|
||||
repository.exchangeSent(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
txId = swapData.transaction.txId,
|
||||
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
fromAddress = fromAddress,
|
||||
payInAddress = payInAddress,
|
||||
txHash = txHash,
|
||||
payInExtraId = swapData.transaction.txExtraId,
|
||||
)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
storeSwapTransaction(
|
||||
finalizeDexSwapSuccess(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
swapProvider = provider,
|
||||
swapDataModel = swapData,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
storeLastCryptoCurrencyId(fromSwapCurrencyStatus)
|
||||
SwapTransactionState.TxSent(
|
||||
fromAmount = amountFormatter.formatSwapAmountToUI(
|
||||
amount,
|
||||
fromSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
fromAmountValue = amount.value,
|
||||
toAmount = amountFormatter.formatSwapAmountToUI(
|
||||
swapData.toTokenAmount,
|
||||
toSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
toAmountValue = swapData.toTokenAmount.value,
|
||||
txHash = txHash,
|
||||
timestamp = timestamp,
|
||||
payInAddress = payInAddress,
|
||||
)
|
||||
},
|
||||
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared success path for DEX (single-tx and integrated approve+swap multi-tx). Notifies the
|
||||
* exchange backend, stores the transaction locally for status tracking, records the last-used
|
||||
* crypto currency id, and returns the [SwapTransactionState.TxSent] payload.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun finalizeDexSwapSuccess(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
provider: SwapProvider,
|
||||
swapData: SwapDataModel,
|
||||
amount: SwapAmount,
|
||||
txHash: String,
|
||||
payInAddress: String,
|
||||
): SwapTransactionState.TxSent {
|
||||
val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress
|
||||
val fromAddress = networkAddress?.defaultAddress?.value.orEmpty()
|
||||
repository.exchangeSent(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
txId = swapData.transaction.txId,
|
||||
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
fromAddress = fromAddress,
|
||||
payInAddress = payInAddress,
|
||||
txHash = txHash,
|
||||
payInExtraId = swapData.transaction.txExtraId,
|
||||
)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
storeSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
swapProvider = provider,
|
||||
swapDataModel = swapData,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
storeLastCryptoCurrencyId(fromSwapCurrencyStatus)
|
||||
return SwapTransactionState.TxSent(
|
||||
fromAmount = amountFormatter.formatSwapAmountToUI(
|
||||
amount,
|
||||
fromSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
fromAmountValue = amount.value,
|
||||
toAmount = amountFormatter.formatSwapAmountToUI(
|
||||
swapData.toTokenAmount,
|
||||
toSwapCurrencyStatus.currency.symbol,
|
||||
),
|
||||
toAmountValue = swapData.toTokenAmount.value,
|
||||
txHash = txHash,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDexTxExtras(data: String, network: Network, gasLimit: Int?): TransactionExtras {
|
||||
return createTransactionExtrasUseCase(
|
||||
data = data,
|
||||
|
|
@ -1029,7 +1157,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
*/
|
||||
@Suppress("LongParameterList")
|
||||
override suspend fun loadSwapFee(
|
||||
provider: SwapProvider,
|
||||
quotesLoadedState: SwapState.QuotesLoadedState,
|
||||
fromStatus: SwapCurrencyStatus,
|
||||
toStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
|
|
@ -1040,13 +1168,14 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
if (amount.value.signum() == 0) {
|
||||
raise(GetFeeError.UnknownError)
|
||||
}
|
||||
return when (provider.type) {
|
||||
return when (quotesLoadedState.swapProvider.type) {
|
||||
ExchangeProviderType.DEX,
|
||||
ExchangeProviderType.DEX_BRIDGE,
|
||||
-> loadDexSwapFee(
|
||||
fromStatus = fromStatus,
|
||||
swapData = swapData,
|
||||
selectedFeeToken = selectedFeeToken,
|
||||
permissionState = quotesLoadedState.permissionState,
|
||||
)
|
||||
ExchangeProviderType.CEX -> loadCexSwapFee(
|
||||
fromStatus = fromStatus,
|
||||
|
|
@ -1058,7 +1187,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX`
|
||||
* DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX`
|
||||
* out of [swapData] and hands it to [DexSwapFeeCalculator]. Maps [ExpressDataError] →
|
||||
* `Left(GetFeeError.UnknownError)` to keep the unified surface a single error type, matching
|
||||
* what the legacy `loadFeeForSwapTransaction` overload 2 does for DEX failures (line 1027 of
|
||||
|
|
@ -1068,13 +1197,26 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
fromStatus: SwapCurrencyStatus,
|
||||
swapData: SwapDataModel?,
|
||||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
permissionState: PermissionDataState,
|
||||
): Either<GetFeeError, SwapFee> {
|
||||
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
|
||||
?: return GetFeeError.UnknownError.left()
|
||||
|
||||
// If the integrated-approve simulation already failed for this context, skip the
|
||||
// simulated estimation entirely and use the plain getFee path (legacy separate-approval flow).
|
||||
val effectivePermissionState = if (
|
||||
permissionState is PermissionDataState.PermissionSettings &&
|
||||
hasIntegratedApprovalFallenBack(fromStatus, permissionState.spenderAddress)
|
||||
) {
|
||||
PermissionDataState.Empty
|
||||
} else {
|
||||
permissionState
|
||||
}
|
||||
|
||||
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
|
||||
val network = (fromStatus.currency as CryptoCurrency.Token).network
|
||||
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
|
||||
// TODO YIELD [REDACTED_TASK_KEY]
|
||||
dexSwapFeeCalculator.calculateYield(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
|
|
@ -1085,11 +1227,12 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedFeeToken,
|
||||
permissionState = effectivePermissionState,
|
||||
)
|
||||
}
|
||||
|
||||
return dexFeeResultEither.fold(
|
||||
ifLeft = { error -> GetFeeError.DataError(error).left() },
|
||||
ifLeft = { error -> error.left() },
|
||||
ifRight = { dexFeeResult ->
|
||||
val feeToken = selectedFeeToken
|
||||
?: resolveNativeFeeTokenStatus(fromStatus)
|
||||
|
|
@ -1111,7 +1254,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
amount: BigDecimal,
|
||||
fee: Fee,
|
||||
spenderAddress: String,
|
||||
): Either<Throwable, TransactionData> {
|
||||
): Either<Throwable, TransactionData.Uncompiled> {
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
|
||||
val network = fromCurrency.network
|
||||
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
|
||||
|
|
@ -1141,7 +1284,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
|
||||
* CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
|
||||
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
|
||||
* decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice
|
||||
* if provided, otherwise the native coin status of the from-token's network.
|
||||
|
|
@ -1174,8 +1317,61 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String) {
|
||||
integratedApprovalFallbackContexts.add(
|
||||
element = IntegratedApprovalFallbackKey(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
fromCurrencyId = fromSwapCurrencyStatus.currency.id,
|
||||
spenderAddress = spenderAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — resolves the native-coin [CryptoCurrencyStatus] for the from-token's network.
|
||||
* Builds the approval [TransactionData.Uncompiled] for the integrated
|
||||
* approval + swap path and loads its [TransactionFee] via [getFeeUseCase]. The amount honors
|
||||
* [ApproveType]: `UNLIMITED` → null (unbounded allowance), `LIMITED` → the swap amount.
|
||||
*/
|
||||
override suspend fun loadIntegratedApprovalData(
|
||||
fromStatus: SwapCurrencyStatus,
|
||||
spenderAddress: String,
|
||||
approveType: ApproveType,
|
||||
approvalAmount: BigDecimal,
|
||||
): Either<GetFeeError, IntegratedApprovalData> = either {
|
||||
val tokenCurrency = fromStatus.currency as? CryptoCurrency.Token
|
||||
?: raise(GetFeeError.DataError(IllegalStateException("Integrated approval requires a Token from-currency")))
|
||||
|
||||
val amountForApprove: BigDecimal? = when (approveType) {
|
||||
ApproveType.LIMITED -> approvalAmount
|
||||
ApproveType.UNLIMITED -> null
|
||||
}
|
||||
|
||||
val approvalTx = createApprovalTransactionUseCase(
|
||||
userWalletId = fromStatus.userWalletId,
|
||||
cryptoCurrencyStatus = fromStatus.status,
|
||||
amount = amountForApprove,
|
||||
contractAddress = tokenCurrency.contractAddress,
|
||||
spenderAddress = spenderAddress,
|
||||
).getOrElse { error ->
|
||||
TangemLogger.e("loadIntegratedApprovalData: failed to create approval tx", error)
|
||||
raise(GetFeeError.DataError(error))
|
||||
}
|
||||
|
||||
val approvalFee = getFeeUseCase(
|
||||
transactionData = approvalTx,
|
||||
userWallet = fromStatus.userWallet,
|
||||
network = fromStatus.currency.network,
|
||||
).bind()
|
||||
|
||||
IntegratedApprovalData(
|
||||
approvalTransaction = approvalTx,
|
||||
approvalFee = approvalFee,
|
||||
approveType = approveType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the native-coin [CryptoCurrencyStatus] for the from-token's network.
|
||||
* Used as the default `selectedFeeToken` of [SwapFee] when the caller did not provide an
|
||||
* explicit choice. Mirrors how `SwapModel.updateFeePaidCryptoCurrencyFor` populates
|
||||
* `dataState.feePaidCryptoCurrency`.
|
||||
|
|
@ -1615,7 +1811,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
* `applySwapFee` is called.
|
||||
* - `currencyCheck`, `validationResult`, `minAdaValue` populated with `fee = 0` (re-derived once fee is known).
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "LongParameterList")
|
||||
private suspend fun loadDexSwapDataNoFee(
|
||||
provider: SwapProvider,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -1667,14 +1863,18 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
provider = provider,
|
||||
)
|
||||
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
|
||||
allowanceInfo is AllowanceInfo.NotEnough
|
||||
allowanceInfo is AllowanceInfo.NotEnough &&
|
||||
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
|
||||
swapState.copy(
|
||||
permissionState = if (isIntegratedApprovalNeeded) {
|
||||
PermissionDataState.PermissionSettings(
|
||||
type = ApproveType.LIMITED,
|
||||
spenderAddress = spenderAddress.orEmpty(),
|
||||
)
|
||||
} else if (allowanceInfo is AllowanceInfo.NotEnough) {
|
||||
} else if (
|
||||
allowanceInfo is AllowanceInfo.NotEnough &&
|
||||
hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
|
||||
) {
|
||||
// Integrated estimation failed earlier this session — show the legacy
|
||||
// separate-approval UI so the user approves before swapping.
|
||||
PermissionDataState.PermissionRequired(
|
||||
|
|
@ -1809,8 +2009,8 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
).getOrNull() ?: return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
|
||||
|
||||
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
|
||||
allowanceInfo is AllowanceInfo.NotEnough
|
||||
|
||||
allowanceInfo is AllowanceInfo.NotEnough &&
|
||||
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, quoteModel.allowanceContract)
|
||||
return quotesLoadedState.copy(
|
||||
permissionState = if (isIntegratedApprovalNeeded) {
|
||||
PermissionDataState.PermissionSettings(
|
||||
|
|
@ -2153,6 +2353,28 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of an integrated-approve fee context, used to remember that the simulated
|
||||
* swap-fee estimation failed (and hence the legacy separate-approval flow must be used). Keyed by
|
||||
* wallet + from-currency + spender; intentionally amount-independent because the estimate-override
|
||||
* failure is structural (the approval simply does not exist yet) and changing the amount cannot fix
|
||||
* it — so we must not retry the simulation on every amount change either.
|
||||
*/
|
||||
private data class IntegratedApprovalFallbackKey(
|
||||
val userWalletId: UserWalletId,
|
||||
val fromCurrencyId: CryptoCurrency.ID,
|
||||
val spenderAddress: String?,
|
||||
) {
|
||||
companion object {
|
||||
fun of(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?): IntegratedApprovalFallbackKey =
|
||||
IntegratedApprovalFallbackKey(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
fromCurrencyId = fromSwapCurrencyStatus.currency.id,
|
||||
spenderAddress = spenderAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [REDACTED_TASK_KEY] — internal classifier replacing the deleted public `IncludeFeeInAmount` enum.
|
||||
* Kept private to [SwapInteractorImpl]; consumers see only [SwapBalanceStatus].
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.tangem.feature.swap.domain.fee
|
|||
|
||||
import android.util.Base64
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
||||
import com.tangem.blockchain.common.Amount
|
||||
|
|
@ -19,14 +21,16 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -66,7 +70,8 @@ class DexSwapFeeCalculator(
|
|||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
selectedToken: CryptoCurrencyStatus? = null,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
): Either<GetFeeError, DexFeeResult> = either {
|
||||
val networkRawId = fromSwapCurrencyStatus.currency.network.rawId
|
||||
val nativeCoinDecimals = Blockchain.fromNetworkId(networkRawId)?.decimals()
|
||||
?: error("Blockchain not found")
|
||||
|
|
@ -82,7 +87,7 @@ class DexSwapFeeCalculator(
|
|||
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
|
||||
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
|
||||
) {
|
||||
raise(ExpressDataError.TooLargeSolanaTransactionError())
|
||||
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
|
||||
}
|
||||
|
||||
val solanaFee = getFeeDataForSolanaDexSwap(
|
||||
|
|
@ -99,6 +104,7 @@ class DexSwapFeeCalculator(
|
|||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedToken,
|
||||
permissionState = permissionState,
|
||||
).bind()
|
||||
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
|
||||
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
|
||||
|
|
@ -143,9 +149,9 @@ class DexSwapFeeCalculator(
|
|||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
yieldModuleAddress: String?,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
): Either<GetFeeError, DexFeeResult> = either {
|
||||
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
?: raise(GetFeeError.UnknownError)
|
||||
val network = fromCurrency.network
|
||||
|
||||
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
|
||||
|
|
@ -153,15 +159,14 @@ class DexSwapFeeCalculator(
|
|||
networkId = network.rawId,
|
||||
derivationPath = network.derivationPath.value,
|
||||
)
|
||||
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
|
||||
if (nativeBalance.signum() == 0) raise(GetFeeError.UnknownError)
|
||||
|
||||
if (yieldModuleAddress == null) {
|
||||
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
|
||||
val gasLimit = transaction.gas ?: raise(GetFeeError.UnknownError)
|
||||
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
|
||||
}
|
||||
|
||||
val spenderAddress = transaction.allowanceContract
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
val spenderAddress = transaction.allowanceContract ?: raise(GetFeeError.UnknownError)
|
||||
|
||||
val rawFee = try {
|
||||
val wrappedCallData = buildYieldSwapCallData(
|
||||
|
|
@ -174,7 +179,7 @@ class DexSwapFeeCalculator(
|
|||
val extras = createTransactionExtrasUseCase(
|
||||
callData = wrappedCallData,
|
||||
network = network,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
).getOrNull() ?: raise(GetFeeError.UnknownError)
|
||||
|
||||
val transactionData = TransactionData.Uncompiled(
|
||||
amount = createNativeAmountForDex("0", network),
|
||||
|
|
@ -187,13 +192,13 @@ class DexSwapFeeCalculator(
|
|||
transactionData = transactionData,
|
||||
network = network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
).getOrNull() ?: raise(GetFeeError.UnknownError)
|
||||
} catch (_: YieldModuleUpgradeUnavailableException) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
raise(GetFeeError.UnknownError)
|
||||
} catch (_: YieldModuleVersionIndeterminateException) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
raise(GetFeeError.UnknownError)
|
||||
} catch (_: IllegalStateException) {
|
||||
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
|
||||
val gasLimit = transaction.gas ?: raise(GetFeeError.UnknownError)
|
||||
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
|
||||
}
|
||||
|
||||
|
|
@ -237,12 +242,12 @@ class DexSwapFeeCalculator(
|
|||
private suspend fun ethSpecificFeeFallback(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
gasLimit: BigInteger,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
): Either<GetFeeError, DexFeeResult> = either {
|
||||
val fee = getEthSpecificFeeUseCase(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
gasLimit = gasLimit,
|
||||
).getOrNull() ?: raise(ExpressDataError.UnknownError())
|
||||
).getOrNull() ?: raise(GetFeeError.UnknownError)
|
||||
val patched = patchEthGasLimitForSwap(fee)
|
||||
DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(patched),
|
||||
|
|
@ -251,68 +256,114 @@ class DexSwapFeeCalculator(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun getFeeDataForDexSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
selectedToken: CryptoCurrencyStatus?,
|
||||
): Either<ExpressDataError, TransactionFeeResult> = either {
|
||||
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
networkId = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
|
||||
)
|
||||
permissionState: PermissionDataState,
|
||||
): Either<GetFeeError, TransactionFeeResult> = either {
|
||||
catch(
|
||||
block = {
|
||||
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
networkId = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
|
||||
)
|
||||
|
||||
// if native balance is zero - we can't calculate fee
|
||||
if (nativeBalance.signum() == 0) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
}
|
||||
// if native balance is zero - we can't calculate fee
|
||||
if (nativeBalance.signum() == 0) {
|
||||
raise(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
try {
|
||||
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
|
||||
val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
|
||||
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
|
||||
val amountToSend = if (permissionState is PermissionDataState.PermissionSettings) {
|
||||
transaction.fromAmount.value.convertToSdkAmount(fromSwapCurrencyStatus.status)
|
||||
} else {
|
||||
createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
|
||||
}
|
||||
|
||||
// transaction.txValue is always native coin
|
||||
if (nativeBalance < amountToSend.value) {
|
||||
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
|
||||
}
|
||||
// transaction.txValue is always native coin
|
||||
if (fromSwapCurrencyStatus.currency is CryptoCurrency.Coin && nativeBalance < amountToSend.value) {
|
||||
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
|
||||
}
|
||||
|
||||
val extras = createTransactionExtrasUseCase(
|
||||
data = transaction.txData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
).getOrNull() ?: error("unable to create extras")
|
||||
|
||||
val transactionData = TransactionData.Uncompiled(
|
||||
amount = amountToSend,
|
||||
destinationAddress = transaction.txTo,
|
||||
fee = null,
|
||||
sourceAddress = transaction.txFrom,
|
||||
extras = extras,
|
||||
)
|
||||
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
|
||||
getFeeForTokenUseCase(
|
||||
transactionData = transactionData,
|
||||
token = selectedToken.currency,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) }
|
||||
?: error("unable to calculate fee for token")
|
||||
} else {
|
||||
getFeeUseCase(
|
||||
transactionData = transactionData,
|
||||
val extras = createTransactionExtrasUseCase(
|
||||
data = transaction.txData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee")
|
||||
}
|
||||
} catch (_: IllegalStateException) {
|
||||
// gas may be null — surface UnknownError so the provider becomes a SwapError.
|
||||
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
|
||||
getEthSpecificFeeUseCase(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
gasLimit = gasLimit,
|
||||
).getOrNull()?.let { TransactionFeeResult.Loaded(it) }
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
).getOrNull() ?: error("unable to create extras")
|
||||
|
||||
val transactionData = TransactionData.Uncompiled(
|
||||
amount = amountToSend,
|
||||
destinationAddress = transaction.txTo,
|
||||
fee = null,
|
||||
sourceAddress = transaction.txFrom,
|
||||
extras = extras,
|
||||
)
|
||||
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
|
||||
getFeeForTokenUseCase(
|
||||
transactionData = transactionData,
|
||||
token = selectedToken.currency,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).fold(
|
||||
// The token branch normally yields LoadedExtended, but when the use case fails
|
||||
// we mirror the exception path and fall back to the eth-specific Loaded fee.
|
||||
ifLeft = { left -> ethSpecificFeeFallbackOrRaise(fromSwapCurrencyStatus, transaction, left) },
|
||||
ifRight = { feeExtended -> TransactionFeeResult.LoadedExtended(feeExtended) },
|
||||
)
|
||||
} else {
|
||||
val isSimulateEstimation = permissionState is PermissionDataState.PermissionSettings
|
||||
getFeeUseCase(
|
||||
transactionData = transactionData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
spenderAddress = (permissionState as? PermissionDataState.PermissionSettings)?.spenderAddress,
|
||||
isSimulateEstimation = isSimulateEstimation,
|
||||
).fold(
|
||||
ifLeft = { left -> ethSpecificFeeFallbackOrRaise(fromSwapCurrencyStatus, transaction, left) },
|
||||
ifRight = { fee -> TransactionFeeResult.Loaded(fee) },
|
||||
)
|
||||
}
|
||||
},
|
||||
catch = { error ->
|
||||
ethSpecificFeeFallbackOrRaise(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
// gas may be null — surface DataError so the provider becomes a SwapError.
|
||||
gasNullError = GetFeeError.DataError(error),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eth-specific fee fallback shared by the inner `catch`'s exception branch and the
|
||||
* `Either.Left` branches of [getFeeForTokenUseCase]/[getFeeUseCase] in [getFeeDataForDexSwap].
|
||||
*
|
||||
* When [ExpressTransactionModel.DEX.gas] is `null` there is no gas limit to feed
|
||||
* [GetEthSpecificFeeUseCase], so [gasNullError] is raised instead. For the exception path
|
||||
* [gasNullError] wraps the thrown [Throwable] as [GetFeeError.DataError]; for the
|
||||
* `Either.Left` path it is the original left [GetFeeError].
|
||||
*
|
||||
* Always returns a [TransactionFeeResult.Loaded] (never `LoadedExtended`), matching the
|
||||
* legacy exception-catch behaviour.
|
||||
*/
|
||||
private suspend fun Raise<GetFeeError>.ethSpecificFeeFallbackOrRaise(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
gasNullError: GetFeeError,
|
||||
): TransactionFeeResult {
|
||||
if (gasNullError is GetFeeError.EstimateOverrideError) {
|
||||
raise(gasNullError)
|
||||
}
|
||||
|
||||
val gasLimit = transaction.gas ?: raise(gasNullError)
|
||||
val fee = getEthSpecificFeeUseCase(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
gasLimit = gasLimit,
|
||||
).bind()
|
||||
return TransactionFeeResult.Loaded(fee)
|
||||
}
|
||||
|
||||
private suspend fun getFeeDataForSolanaDexSwap(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.tangem.feature.swap.domain.models.ui
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
|
|
@ -19,19 +21,25 @@ import java.math.BigDecimal
|
|||
sealed interface SwapState {
|
||||
|
||||
data class QuotesLoadedState(
|
||||
// Quote info
|
||||
val fromTokenInfo: TokenSwapInfo,
|
||||
val toTokenInfo: TokenSwapInfo,
|
||||
val swapProvider: SwapProvider,
|
||||
// Quote UI state
|
||||
val priceImpact: PriceImpact,
|
||||
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
val permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
// Quote tx
|
||||
val swapDataModel: SwapDataModel? = null,
|
||||
val integratedApprovalData: IntegratedApprovalData? = null,
|
||||
// Quote validation & checking
|
||||
val currencyCheck: CryptoCurrencyCheck? = null,
|
||||
val validationResult: Throwable? = null,
|
||||
val minAdaValue: BigDecimal?,
|
||||
val swapProvider: SwapProvider,
|
||||
|
||||
) : SwapState
|
||||
|
||||
data class Transfer(
|
||||
|
|
@ -119,4 +127,25 @@ data class TokenSwapInfo(
|
|||
val tokenAmount: SwapAmount,
|
||||
val amountFiat: BigDecimal,
|
||||
val swapCurrencyStatus: SwapCurrencyStatus,
|
||||
)
|
||||
|
||||
/**
|
||||
* Combined approval + swap data attached to a [SwapState.QuotesLoadedState] when the user must approve a token
|
||||
* spend before swapping. Carries both the prepared approval transaction (built off the current
|
||||
* `permissionState.type` / spender) and the approval fee [TransactionFee] so the user-selected
|
||||
* fee bucket can be applied at submission time.
|
||||
*
|
||||
* The swap-tx data is not stored here — it is rebuilt fresh from `swapDataModel` at submission
|
||||
* time so any provider-side payload changes are picked up.
|
||||
*
|
||||
* @property approvalTransaction the unsigned ERC-20 approve transaction body, fee unset.
|
||||
* @property approvalFee the loaded fee envelope (Choosable or Single) for the approval tx; used
|
||||
* to pick min/normal/priority based on the user's [FeeBucket] selection.
|
||||
* @property approveType the user-selected approval type (LIMITED vs UNLIMITED) the
|
||||
* [approvalTransaction] was built for. Tracked so the model can detect a recalc-needed change.
|
||||
*/
|
||||
data class IntegratedApprovalData(
|
||||
val approvalTransaction: TransactionData.Uncompiled,
|
||||
val approvalFee: TransactionFee,
|
||||
val approveType: ApproveType,
|
||||
)
|
||||
|
|
@ -227,45 +227,44 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
* whose balance (5.0) comfortably exceeds the fee (0.001).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() =
|
||||
runTest {
|
||||
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns gaslessTokenId
|
||||
}
|
||||
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns gaslessToken
|
||||
every { value.amount } returns BigDecimal("5.0")
|
||||
}
|
||||
|
||||
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
|
||||
tokenId = gaslessTokenId,
|
||||
name = "GasToken",
|
||||
symbol = "GAS",
|
||||
contractAddress = "0xGasTokenAddress",
|
||||
balance = BigDecimal("5.0"),
|
||||
)
|
||||
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val state = buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
fromTokenId = fromId,
|
||||
)
|
||||
// selectedFeeToken is the gasless token (different from fromToken)
|
||||
val fee = buildSwapFeeWithExplicitToken(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
tokenStatus = gaslessTokenStatus,
|
||||
tokenId = gaslessTokenId,
|
||||
)
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() = runTest {
|
||||
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns gaslessTokenId
|
||||
}
|
||||
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns gaslessToken
|
||||
every { value.amount } returns BigDecimal("5.0")
|
||||
}
|
||||
|
||||
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
|
||||
tokenId = gaslessTokenId,
|
||||
name = "GasToken",
|
||||
symbol = "GAS",
|
||||
contractAddress = "0xGasTokenAddress",
|
||||
balance = BigDecimal("5.0"),
|
||||
)
|
||||
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val state = buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
fromTokenId = fromId,
|
||||
)
|
||||
// selectedFeeToken is the gasless token (different from fromToken)
|
||||
val fee = buildSwapFeeWithExplicitToken(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
tokenStatus = gaslessTokenStatus,
|
||||
tokenId = gaslessTokenId,
|
||||
)
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.Token with insufficient token balance → InsufficientFee.
|
||||
|
|
@ -534,26 +533,25 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
* No amount+fee concern because the fee currency (ETH) != from-token (USDC).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.5")
|
||||
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.5")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("200.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("200.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* From-token is an ERC-20 Token, FeePaidCurrency.Coin.
|
||||
|
|
@ -713,7 +711,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest
|
|||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("0.999"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
|
||||
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.005"))
|
||||
|
||||
|
|
|
|||
|
|
@ -234,10 +234,7 @@ internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase()
|
|||
)
|
||||
}
|
||||
|
||||
private fun buildSwapFee(
|
||||
feeValue: BigDecimal,
|
||||
otherNativeFee: BigDecimal = BigDecimal.ZERO,
|
||||
): SwapFee {
|
||||
private fun buildSwapFee(feeValue: BigDecimal, otherNativeFee: BigDecimal = BigDecimal.ZERO): SwapFee {
|
||||
val amount = mockk<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,12 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
|
|||
// we can validate routing by which side-effects ran (allowance + exchangeData for DEX,
|
||||
// neither for CEX).
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
cryptoCurrency = any(),
|
||||
spenderAddress = any(),
|
||||
requiredAmount = any(),
|
||||
)
|
||||
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
|
|
@ -113,104 +118,109 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX provider with quote txType SEND on EVM WHEN findBestQuote THEN routes to manageCex path`() = runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
|
||||
stubFindBestQuote(provider, quote)
|
||||
fun `GIVEN DEX provider with quote txType SEND on EVM WHEN findBestQuote THEN routes to manageCex path`() =
|
||||
runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "dex-with-send")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
|
||||
stubFindBestQuote(provider, quote)
|
||||
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertManageCexPathTaken(result, provider)
|
||||
}
|
||||
assertManageCexPathTaken(result, provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX provider with quote txType SWAP on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "real-dex")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
|
||||
stubFindBestQuote(provider, quote)
|
||||
fun `GIVEN DEX provider with quote txType SWAP on EVM WHEN findBestQuote THEN routes to manageDex path`() =
|
||||
runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "real-dex")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
|
||||
stubFindBestQuote(provider, quote)
|
||||
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX provider with quote txType null on EVM WHEN findBestQuote THEN routes to manageDex path`() = runTest {
|
||||
// Legacy backend that hasn't started returning txType on quote yet.
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "legacy-dex")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = null)
|
||||
stubFindBestQuote(provider, quote)
|
||||
fun `GIVEN DEX provider with quote txType null on EVM WHEN findBestQuote THEN routes to manageDex path`() =
|
||||
runTest {
|
||||
// Legacy backend that hasn't started returning txType on quote yet.
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX, providerId = "legacy-dex")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = null)
|
||||
stubFindBestQuote(provider, quote)
|
||||
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DEX_BRIDGE provider on EVM
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX_BRIDGE provider with quote txType SEND WHEN findBestQuote THEN routes to manageCex path`() = runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "bridge-send")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
|
||||
stubFindBestQuote(provider, quote)
|
||||
fun `GIVEN DEX_BRIDGE provider with quote txType SEND WHEN findBestQuote THEN routes to manageCex path`() =
|
||||
runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "bridge-send")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = null, txType = ExpressTxType.SEND)
|
||||
stubFindBestQuote(provider, quote)
|
||||
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertManageCexPathTaken(result, provider)
|
||||
}
|
||||
assertManageCexPathTaken(result, provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX_BRIDGE provider with quote txType SWAP WHEN findBestQuote THEN routes to manageDex path`() = runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "li-fi-like")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
|
||||
stubFindBestQuote(provider, quote)
|
||||
fun `GIVEN DEX_BRIDGE provider with quote txType SWAP WHEN findBestQuote THEN routes to manageDex path`() =
|
||||
runTest {
|
||||
val provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, providerId = "li-fi-like")
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val quote = buildQuoteModel(allowanceContract = "0xSpender", txType = ExpressTxType.SWAP)
|
||||
stubFindBestQuote(provider, quote)
|
||||
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = from,
|
||||
toSwapCurrencyStatus = to,
|
||||
providers = listOf(provider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
assertManageDexPathTaken(result, provider)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CEX provider — regression guard
|
||||
|
|
@ -306,14 +316,18 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
|
|||
* loadDexSwapDataNoFee, only on the DEX path).
|
||||
* - getAllowanceInfoUseCase NOT called (DEX-only artifact).
|
||||
*/
|
||||
private fun assertManageCexPathTaken(
|
||||
result: Map<SwapProvider, SwapState>,
|
||||
provider: SwapProvider,
|
||||
) {
|
||||
private fun assertManageCexPathTaken(result: Map<SwapProvider, SwapState>, provider: SwapProvider) {
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
|
||||
coVerify(exactly = 0) { getAllowanceInfoUseCase.invoke(any(), any(), any(), any()) }
|
||||
coVerify(exactly = 0) {
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
cryptoCurrency = any(),
|
||||
spenderAddress = any(),
|
||||
requiredAmount = any(),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(),
|
||||
|
|
@ -340,10 +354,7 @@ internal class SwapInteractorImplBridgeReRouteTest : SwapInteractorImplTestBase(
|
|||
* Right and balance is sufficient (the default setup ensures this). The presence of that
|
||||
* call is therefore a reliable signal that the bridge re-route did NOT fire.
|
||||
*/
|
||||
private fun assertManageDexPathTaken(
|
||||
result: Map<SwapProvider, SwapState>,
|
||||
provider: SwapProvider,
|
||||
) {
|
||||
private fun assertManageDexPathTaken(result: Map<SwapProvider, SwapState>, provider: SwapProvider) {
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat(result[provider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
coVerify(atLeast = 1) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
|||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
|
|
@ -1129,6 +1130,121 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular (non-yield) DEX swap with the integrated-approve toggle ON: the
|
||||
* `isAllowanceSatisfied` matrix in `manageDex`.
|
||||
*
|
||||
* - Integrated-active treats `NotEnough` as satisfied (only `ResetNeeded` blocks), so the flow
|
||||
* proceeds to `loadDexSwapDataNoFee` → `PermissionSettings` (bundled approve+swap).
|
||||
* - `ResetNeeded` is NOT satisfied → the flow does NOT proceed to exchange-data loading.
|
||||
* - `Enough` proceeds with `permissionState = Empty` (nothing to approve).
|
||||
*/
|
||||
@Nested
|
||||
inner class IntegratedApprovalActivationRegularSwap {
|
||||
|
||||
private val spender = "0xDexRouter"
|
||||
private val tokenContract = "0xRegularToken"
|
||||
|
||||
@BeforeEach
|
||||
fun enableIntegrated() {
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NotEnough allowance with integrated active proceeds to PermissionSettings`() = runTest {
|
||||
stubAllowanceForSpender(
|
||||
AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE),
|
||||
)
|
||||
val dexProvider = stubTokenDexQuoteAndExchangeData()
|
||||
|
||||
val result = invokeRegularToken(dexProvider)
|
||||
|
||||
val loaded = result[dexProvider] as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
|
||||
assertThat((loaded.permissionState as PermissionDataState.PermissionSettings).spenderAddress)
|
||||
.isEqualTo(spender)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ResetNeeded allowance with integrated active does NOT proceed to exchange data`() = runTest {
|
||||
stubAllowanceForSpender(
|
||||
AllowanceInfo.ResetNeeded(allowance = BigDecimal("0.5"), requiredAmount = BigDecimal.ONE),
|
||||
)
|
||||
val dexProvider = stubTokenDexQuoteAndExchangeData()
|
||||
|
||||
invokeRegularToken(dexProvider)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
|
||||
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
|
||||
providerId = any(), rateType = any(), toAddress = any(),
|
||||
expressOperationType = any(), refundAddress = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enough allowance with integrated active proceeds with permission Empty`() = runTest {
|
||||
stubAllowanceForSpender(AllowanceInfo.Enough(allowance = BigDecimal("100")))
|
||||
val dexProvider = stubTokenDexQuoteAndExchangeData()
|
||||
|
||||
val result = invokeRegularToken(dexProvider)
|
||||
|
||||
val loaded = result[dexProvider] as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
private fun stubAllowanceForSpender(info: AllowanceInfo) {
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(), cryptoCurrency = any(),
|
||||
spenderAddress = any(), requiredAmount = any(),
|
||||
)
|
||||
} returns info.right()
|
||||
}
|
||||
|
||||
private fun stubTokenDexQuoteAndExchangeData(): com.tangem.feature.swap.domain.models.domain.SwapProvider {
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val quoteModel = buildQuoteModel(allowanceContract = spender)
|
||||
val swapData = buildSwapDataModelDex()
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
|
||||
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
|
||||
expressOperationType = any(), refundAddress = any(),
|
||||
)
|
||||
} returns swapData.right()
|
||||
return dexProvider
|
||||
}
|
||||
|
||||
private suspend fun invokeRegularToken(
|
||||
dexProvider: com.tangem.feature.swap.domain.models.domain.SwapProvider,
|
||||
) = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = tokenContract,
|
||||
isCoin = false,
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region — test-local helpers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.fee.DexFeeResult
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tests for the integrated-approval fallback context in [SwapInteractorImpl]
|
||||
* ([SwapInteractorImpl.integratedApprovalFallback] / private `hasIntegratedApprovalFallenBack`).
|
||||
*
|
||||
* The fallback context is keyed by `(userWalletId, currency.id, spenderAddress)`. Once
|
||||
* [SwapInteractorImpl.integratedApprovalFallback] records a context, a subsequent
|
||||
* [SwapInteractorImpl.loadSwapFee] with a matching `PermissionSettings.spenderAddress` must
|
||||
* downgrade the calculator's `permissionState` to [PermissionDataState.Empty] (legacy
|
||||
* separate-approval flow). A DIFFERENT spender must NOT be downgraded.
|
||||
*
|
||||
* Observed through the public `loadSwapFee` path: the `DexSwapFeeCalculator` is mocked and its
|
||||
* `permissionState` argument is captured.
|
||||
*
|
||||
* NOTE: the fallback key uses `currency.id`, which is a relaxed mock with reference equality.
|
||||
* Each `buildSwapCurrencyStatus(...)` call produces a fresh `currency.id`, so the SAME
|
||||
* `fromStatus` instance must be reused across the `integratedApprovalFallback` call and the
|
||||
* `loadSwapFee` call for the key to match.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplIntegratedApprovalFallbackTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val nativeFeeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
coEvery {
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
|
||||
} returns nativeFeeTokenStatus.right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fallback recorded for matching spender THEN loadSwapFee downgrades permissionState to Empty`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val permissionSlot = slot<PermissionDataState>()
|
||||
stubCalculatorCapturing(permissionSlot)
|
||||
|
||||
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fromStatus, spenderAddress = SPENDER)
|
||||
|
||||
sut.loadSwapFee(
|
||||
quotesLoadedState = buildPermissionSettingsState(SPENDER),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = buildSwapData(),
|
||||
selectedFeeToken = null,
|
||||
isGasless = false
|
||||
)
|
||||
|
||||
assertThat(permissionSlot.captured).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no fallback recorded THEN loadSwapFee passes the original PermissionSettings`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val permissionSlot = slot<PermissionDataState>()
|
||||
stubCalculatorCapturing(permissionSlot)
|
||||
|
||||
sut.loadSwapFee(
|
||||
quotesLoadedState = buildPermissionSettingsState(SPENDER),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = buildSwapData(),
|
||||
selectedFeeToken = null,
|
||||
isGasless = false
|
||||
)
|
||||
|
||||
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
|
||||
assertThat((permissionSlot.captured as PermissionDataState.PermissionSettings).spenderAddress)
|
||||
.isEqualTo(SPENDER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fallback recorded for a different spender THEN loadSwapFee is NOT downgraded`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val permissionSlot = slot<PermissionDataState>()
|
||||
stubCalculatorCapturing(permissionSlot)
|
||||
|
||||
// Record the fallback for a DIFFERENT spender than the one in the loaded state.
|
||||
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fromStatus, spenderAddress = OTHER_SPENDER)
|
||||
|
||||
sut.loadSwapFee(
|
||||
quotesLoadedState = buildPermissionSettingsState(SPENDER),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = buildSwapData(),
|
||||
selectedFeeToken = null,
|
||||
isGasless = false
|
||||
)
|
||||
|
||||
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
|
||||
assertThat((permissionSlot.captured as PermissionDataState.PermissionSettings).spenderAddress)
|
||||
.isEqualTo(SPENDER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fallback recorded for a different from-currency THEN loadSwapFee is NOT downgraded`() = runTest {
|
||||
// Fallback recorded for one currency instance, fee loaded for a different instance with
|
||||
// the same spender → keys differ on currency.id → no downgrade.
|
||||
val fallbackFromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val feeFromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val permissionSlot = slot<PermissionDataState>()
|
||||
stubCalculatorCapturing(permissionSlot)
|
||||
|
||||
sut.integratedApprovalFallback(fromSwapCurrencyStatus = fallbackFromStatus, spenderAddress = SPENDER)
|
||||
|
||||
sut.loadSwapFee(
|
||||
quotesLoadedState = buildPermissionSettingsState(SPENDER),
|
||||
fromStatus = feeFromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = buildSwapData(),
|
||||
selectedFeeToken = null,
|
||||
isGasless = false
|
||||
)
|
||||
|
||||
assertThat(permissionSlot.captured).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun stubCalculatorCapturing(permissionSlot: io.mockk.CapturingSlot<PermissionDataState>) {
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = capture(permissionSlot),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(
|
||||
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
|
||||
),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
).right()
|
||||
}
|
||||
|
||||
private fun buildPermissionSettingsState(spender: String): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapCurrencyStatus = from,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
swapCurrencyStatus = to,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.PermissionSettings(
|
||||
type = ApproveType.UNLIMITED,
|
||||
spenderAddress = spender,
|
||||
),
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSwapData(): SwapDataModel = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
toAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
txValue = "1000000000000000",
|
||||
txId = "tx-id",
|
||||
txTo = "0xTo",
|
||||
txExtraId = null,
|
||||
txFrom = "0xFrom",
|
||||
txData = "dGVzdA==",
|
||||
otherNativeFeeWei = null,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
const val OTHER_SPENDER = "0xOtherSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -12,9 +12,11 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
|||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -76,7 +78,12 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
|
|||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
cryptoCurrency = any(),
|
||||
spenderAddress = any(),
|
||||
requiredAmount = any(),
|
||||
)
|
||||
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
|
||||
}
|
||||
|
||||
|
|
@ -157,4 +164,131 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
|
|||
// Fee calculator must not be invoked during quote loading.
|
||||
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// region permission-state selection on AllowanceInfo.NotEnough
|
||||
|
||||
@Test
|
||||
fun `GIVEN NotEnough allowance AND integrated active THEN permissionState is PermissionSettings`() = runTest {
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
|
||||
stubAllowance(AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE))
|
||||
|
||||
val state = runFindBestQuoteForToken()
|
||||
|
||||
val permission = state.permissionState
|
||||
assertThat(permission).isInstanceOf(PermissionDataState.PermissionSettings::class.java)
|
||||
assertThat((permission as PermissionDataState.PermissionSettings).spenderAddress).isEqualTo(SPENDER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Enough allowance THEN permissionState is Empty`() = runTest {
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
|
||||
stubAllowance(AllowanceInfo.Enough(allowance = BigDecimal("100")))
|
||||
|
||||
val state = runFindBestQuoteForToken()
|
||||
|
||||
assertThat(state.permissionState).isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN NotEnough allowance AND integrated toggle OFF THEN does not reach loadDexSwapDataNoFee`() = runTest {
|
||||
// With the integrated toggle off, NotEnough is not allowance-satisfied (requires Enough),
|
||||
// so manageDex does NOT enter loadDexSwapDataNoFee — getExchangeData is never called.
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns false
|
||||
stubAllowance(AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE))
|
||||
|
||||
runFindBestQuoteForTokenRaw()
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
|
||||
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
|
||||
providerId = any(), rateType = any(), toAddress = any(),
|
||||
expressOperationType = any(), refundAddress = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun stubAllowance(info: AllowanceInfo) {
|
||||
coEvery {
|
||||
getAllowanceInfoUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
cryptoCurrency = any(),
|
||||
spenderAddress = any(),
|
||||
requiredAmount = any(),
|
||||
)
|
||||
} returns info.right()
|
||||
}
|
||||
|
||||
/** Runs findBestQuote for a token from-currency whose quote carries [SPENDER] as allowanceContract. */
|
||||
private suspend fun runFindBestQuoteForToken(): SwapState.QuotesLoadedState {
|
||||
val dexProvider = stubDexQuoteAndExchangeData()
|
||||
val result = invokeFindBestQuote(dexProvider)
|
||||
return result[dexProvider] as SwapState.QuotesLoadedState
|
||||
}
|
||||
|
||||
private suspend fun runFindBestQuoteForTokenRaw() {
|
||||
val dexProvider = stubDexQuoteAndExchangeData()
|
||||
invokeFindBestQuote(dexProvider)
|
||||
}
|
||||
|
||||
private fun stubDexQuoteAndExchangeData(): com.tangem.feature.swap.domain.models.domain.SwapProvider {
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val quoteModel = buildQuoteModel(allowanceContract = SPENDER)
|
||||
val swapDataModel = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
toAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
txValue = "1000000000000000000",
|
||||
txId = "tx-id",
|
||||
txTo = "0xToAddress",
|
||||
txExtraId = null,
|
||||
txFrom = "0xFromAddress",
|
||||
txData = "0xdata",
|
||||
otherNativeFeeWei = null,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = SPENDER,
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
|
||||
fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
|
||||
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
|
||||
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
|
||||
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
|
||||
expressOperationType = any(), refundAddress = any(),
|
||||
)
|
||||
} returns swapDataModel.right()
|
||||
return dexProvider
|
||||
}
|
||||
|
||||
private suspend fun invokeFindBestQuote(
|
||||
dexProvider: com.tangem.feature.swap.domain.models.domain.SwapProvider,
|
||||
) = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
isCoin = false,
|
||||
contractAddress = "0xToken",
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork),
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.loadIntegratedApprovalData].
|
||||
*
|
||||
* Builds the ERC-20 approval transaction (via [createApprovalTransactionUseCase]) and loads its
|
||||
* [TransactionFee] (via [getFeeUseCase]). Honors [ApproveType]:
|
||||
* - `LIMITED` → approval amount = the passed swap amount.
|
||||
* - `UNLIMITED` → approval amount = null (unbounded allowance).
|
||||
*
|
||||
* Error surfaces:
|
||||
* - non-Token from-currency → `Left(GetFeeError.DataError)`.
|
||||
* - `createApprovalTransactionUseCase` Left (throwable) → `Left(GetFeeError.DataError)`.
|
||||
* - `getFeeUseCase` Left → propagated as Left verbatim.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val approvalTx = mockk<TransactionData.Uncompiled>(relaxed = true)
|
||||
private val approvalFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase.invoke(
|
||||
cryptoCurrencyStatus = any(),
|
||||
userWalletId = any(),
|
||||
amount = any(),
|
||||
contractAddress = any(),
|
||||
spenderAddress = any(),
|
||||
)
|
||||
} returns approvalTx.right()
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
transactionData = any(),
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
)
|
||||
} returns approvalFee.right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN LIMITED THEN approval amount equals the swap amount`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val amountCaptures = mutableListOf<BigDecimal?>()
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase.invoke(
|
||||
cryptoCurrencyStatus = any(),
|
||||
userWalletId = any(),
|
||||
amount = captureNullable(amountCaptures),
|
||||
contractAddress = any(),
|
||||
spenderAddress = any(),
|
||||
)
|
||||
} returns approvalTx.right()
|
||||
|
||||
val result = sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.LIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { data ->
|
||||
assertThat(data).isInstanceOf(IntegratedApprovalData::class.java)
|
||||
assertThat(data.approveType).isEqualTo(ApproveType.LIMITED)
|
||||
assertThat(data.approvalFee).isEqualTo(approvalFee)
|
||||
assertThat(data.approvalTransaction).isEqualTo(approvalTx)
|
||||
}
|
||||
assertThat(amountCaptures.single()).isEqualTo(SWAP_AMOUNT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN UNLIMITED THEN approval amount is null`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val amountCaptures = mutableListOf<BigDecimal?>()
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase.invoke(
|
||||
cryptoCurrencyStatus = any(),
|
||||
userWalletId = any(),
|
||||
amount = captureNullable(amountCaptures),
|
||||
contractAddress = any(),
|
||||
spenderAddress = any(),
|
||||
)
|
||||
} returns approvalTx.right()
|
||||
|
||||
val result = sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.UNLIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { data -> assertThat(data.approveType).isEqualTo(ApproveType.UNLIMITED) }
|
||||
assertThat(amountCaptures.single()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-Token from-currency THEN returns Left DataError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = true)
|
||||
|
||||
val result = sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.LIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) }
|
||||
coVerify(exactly = 0) {
|
||||
createApprovalTransactionUseCase.invoke(
|
||||
cryptoCurrencyStatus = any(),
|
||||
userWalletId = any(),
|
||||
amount = any(),
|
||||
contractAddress = any(),
|
||||
spenderAddress = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN createApprovalTransaction Left THEN returns Left DataError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase.invoke(
|
||||
cryptoCurrencyStatus = any(),
|
||||
userWalletId = any(),
|
||||
amount = any(),
|
||||
contractAddress = any(),
|
||||
spenderAddress = any(),
|
||||
)
|
||||
} returns IllegalStateException("cannot build approval tx").left()
|
||||
|
||||
val result = sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.LIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) }
|
||||
coVerify(exactly = 0) {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN getFeeUseCase Left THEN propagates the Left error verbatim`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns GetFeeError.BlockchainErrors.TronActivationError.left()
|
||||
|
||||
val result = sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.LIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
const val CONTRACT = "0xContract"
|
||||
val SWAP_AMOUNT: BigDecimal = BigDecimal("12.34")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,14 +16,12 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended
|
|||
import com.tangem.feature.swap.domain.fee.CexFeeResult
|
||||
import com.tangem.feature.swap.domain.fee.DexFeeResult
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeBucket
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -81,7 +79,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
)
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
|
|
@ -89,7 +92,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -106,7 +109,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
dexSwapFeeCalculator.calculate(fromStatus, transaction, null)
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = null,
|
||||
permissionState = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +133,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
),
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
|
|
@ -133,7 +146,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 9),
|
||||
|
|
@ -159,7 +172,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
transaction = transaction,
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(
|
||||
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
|
||||
|
|
@ -169,7 +187,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -190,7 +208,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -203,7 +221,14 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
|
||||
coVerify(exactly = 0) {
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -212,7 +237,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -228,35 +253,6 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = buildDexTransaction(),
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns ExpressDataError.UnknownError().left()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null, isGasless = false,
|
||||
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CEX branch
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -267,18 +263,24 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
|
||||
// Gasless picked native — feeTokenId points at the network's coin.
|
||||
io.mockk.every { transactionFee } returns TransactionFee.Single(
|
||||
every { transactionFee } returns TransactionFee.Single(
|
||||
normal = mockk<Fee.Common>(relaxed = true),
|
||||
)
|
||||
}
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -300,8 +302,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
amount = BigDecimal.ONE,
|
||||
selectedFeeToken = null,
|
||||
isGasless = true,
|
||||
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,13 +318,19 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -344,17 +351,23 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -383,17 +396,23 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val explicitNativeStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
}
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -414,11 +433,17 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any())
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -443,7 +468,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ZERO, 18),
|
||||
|
|
@ -456,7 +481,15 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) }
|
||||
coVerify(exactly = 0) {
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = any(),
|
||||
fromSwapCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
selectedFeeToken = any(),
|
||||
isGasless = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -469,7 +502,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ZERO, 18),
|
||||
|
|
@ -483,7 +516,14 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
|
||||
coVerify(exactly = 0) {
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -501,11 +541,16 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
transaction = transaction,
|
||||
)
|
||||
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
|
|
@ -513,7 +558,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -528,7 +573,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
dexSwapFeeCalculator.calculate(fromStatus, transaction, explicitTokenStatus)
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = explicitTokenStatus,
|
||||
permissionState = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,7 +595,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
* This exercises the `resolveNativeFeeTokenStatus` fallback path in loadDexSwapFee.
|
||||
*/
|
||||
@Test
|
||||
fun `DEX with null selectedFeeToken — resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
|
||||
fun `DEX with null selectedFeeToken - resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
|
||||
runTest {
|
||||
// Primary resolve: getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null)
|
||||
// → triggers the fallback block in resolveNativeFeeTokenStatus
|
||||
|
|
@ -560,7 +610,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
networkRawId = ethNetwork,
|
||||
isCoin = true,
|
||||
)
|
||||
io.mockk.every {
|
||||
every {
|
||||
fromStatusWithNullAddr.status.value.networkAddress
|
||||
} returns null
|
||||
|
||||
|
|
@ -576,7 +626,12 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
// quotesRepository returns null → NoQuote path → networkAddress null → return@run null
|
||||
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns null
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
dexSwapFeeCalculator.calculate(
|
||||
fromSwapCurrencyStatus = any(),
|
||||
transaction = any(),
|
||||
selectedToken = any(),
|
||||
permissionState = any(),
|
||||
)
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(
|
||||
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
|
||||
|
|
@ -586,7 +641,7 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatusWithNullAddr,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
|
|
@ -606,19 +661,49 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase()
|
|||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildDexTransaction(
|
||||
otherNativeFeeWei: BigDecimal? = null,
|
||||
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
toAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
txValue = "1000000000000000",
|
||||
txId = "tx-id",
|
||||
txTo = "0xTo",
|
||||
txExtraId = null,
|
||||
txFrom = "0xFrom",
|
||||
txData = "dGVzdA==",
|
||||
otherNativeFeeWei = otherNativeFeeWei,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
)
|
||||
private fun buildQuotesLoadedState(
|
||||
providerType: ExchangeProviderType,
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapCurrencyStatus = from,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
swapCurrencyStatus = to,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = permissionState,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(providerType),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDexTransaction(otherNativeFeeWei: BigDecimal? = null): ExpressTransactionModel.DEX =
|
||||
ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
toAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
txValue = "1000000000000000",
|
||||
txId = "tx-id",
|
||||
txTo = "0xTo",
|
||||
txExtraId = null,
|
||||
txFrom = "0xFrom",
|
||||
txData = "dGVzdA==",
|
||||
otherNativeFeeWei = otherNativeFeeWei,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
allowanceContract = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -2,22 +2,35 @@ package com.tangem.feature.swap.domain
|
|||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.TransactionExtras
|
||||
import com.tangem.blockchain.common.TransactionSender
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.express.models.ExpressOperationType
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeBucket
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -109,6 +122,170 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
|
|||
coVerifyGetExchangeData(times = 1)
|
||||
}
|
||||
|
||||
// region integrated approve+swap (sendIntegratedApproveAndSwap)
|
||||
|
||||
@Test
|
||||
fun `GIVEN integratedApproval WHEN onSwap THEN sends approve plus swap as one DEFAULT batch and swap hash is last`() =
|
||||
runTest {
|
||||
stubSwapTxCreated()
|
||||
val txsSlot = slot<List<TransactionData>>()
|
||||
coEvery {
|
||||
sendTransactionUseCase(
|
||||
txsData = capture(txsSlot),
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
|
||||
)
|
||||
} returns listOf(APPROVAL_HASH, SWAP_HASH).right()
|
||||
|
||||
val result = onSwapIntegrated(integratedApproval = integratedApproval(approvalFee = singleFee()))
|
||||
|
||||
// approval tx first, swap tx last → 2 txs in a single batch.
|
||||
assertThat(txsSlot.captured).hasSize(2)
|
||||
assertThat(txsSlot.captured.first()).isInstanceOf(TransactionData.Uncompiled::class.java)
|
||||
// Success carries the LAST hash (the swap tx); approval hash is dropped.
|
||||
assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java)
|
||||
assertThat((result as SwapTransactionState.TxSent).txHash).isEqualTo(SWAP_HASH)
|
||||
coVerify(exactly = 1) {
|
||||
sendTransactionUseCase(
|
||||
txsData = any(),
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
sendMode = TransactionSender.MultipleTransactionSendMode.DEFAULT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN integratedApproval AND send fails WHEN onSwap THEN returns TransactionError`() = runTest {
|
||||
stubSwapTxCreated()
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns SendTransactionError.UnknownError(Exception("boom")).left()
|
||||
|
||||
val result = onSwapIntegrated(integratedApproval = integratedApproval(approvalFee = singleFee()))
|
||||
|
||||
assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable approval fee AND SLOW bucket THEN approval tx fee is the minimum`() = runTest {
|
||||
assertApprovalFeeBucket(
|
||||
approvalFee = choosableFee(),
|
||||
bucket = FeeBucket.SLOW,
|
||||
expectedFee = MIN_FEE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable approval fee AND FAST bucket THEN approval tx fee is the priority`() = runTest {
|
||||
assertApprovalFeeBucket(
|
||||
approvalFee = choosableFee(),
|
||||
bucket = FeeBucket.FAST,
|
||||
expectedFee = PRIORITY_FEE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable approval fee AND MARKET bucket THEN approval tx fee is the normal`() = runTest {
|
||||
assertApprovalFeeBucket(
|
||||
approvalFee = choosableFee(),
|
||||
bucket = FeeBucket.MARKET,
|
||||
expectedFee = NORMAL_FEE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Single approval fee THEN approval tx fee is the normal regardless of bucket`() = runTest {
|
||||
assertApprovalFeeBucket(
|
||||
approvalFee = singleFee(),
|
||||
bucket = FeeBucket.SLOW,
|
||||
expectedFee = NORMAL_FEE,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun assertApprovalFeeBucket(
|
||||
approvalFee: TransactionFee,
|
||||
bucket: FeeBucket,
|
||||
expectedFee: Fee,
|
||||
) {
|
||||
stubSwapTxCreated()
|
||||
val txsSlot = slot<List<TransactionData>>()
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = capture(txsSlot), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns listOf(APPROVAL_HASH, SWAP_HASH).right()
|
||||
|
||||
onSwapIntegrated(
|
||||
integratedApproval = integratedApproval(approvalFee = approvalFee),
|
||||
swapFee = buildSwapFee(feeBucket = bucket),
|
||||
)
|
||||
|
||||
val approvalTx = txsSlot.captured.first() as TransactionData.Uncompiled
|
||||
assertThat(approvalTx.fee).isEqualTo(expectedFee)
|
||||
}
|
||||
|
||||
private fun stubSwapTxCreated() {
|
||||
// The swap tx must be a real Uncompiled so getPayoutAddress(swapTxData) resolves.
|
||||
coEvery {
|
||||
createTransactionUseCase(
|
||||
amount = any(), fee = any(), memo = any(),
|
||||
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
|
||||
)
|
||||
} returns swapTxUncompiled().right()
|
||||
}
|
||||
|
||||
private suspend fun onSwapIntegrated(
|
||||
integratedApproval: IntegratedApprovalData,
|
||||
swapFee: SwapFee = buildSwapFee(feeBucket = FeeBucket.MARKET),
|
||||
): SwapTransactionState = sut.onSwap(
|
||||
fromSwapCurrencyStatus = hotStatus(),
|
||||
toSwapCurrencyStatus = hotStatus(),
|
||||
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
swapData = dexSwapData(),
|
||||
amountToSwap = "1.0",
|
||||
balanceStatus = SwapBalanceStatus.Sufficient,
|
||||
fee = swapFee,
|
||||
expressOperationType = ExpressOperationType.SWAP,
|
||||
isTangemPayWithdrawal = false,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
|
||||
private fun integratedApproval(approvalFee: TransactionFee): IntegratedApprovalData = IntegratedApprovalData(
|
||||
approvalTransaction = approvalTxUncompiled(),
|
||||
approvalFee = approvalFee,
|
||||
approveType = ApproveType.UNLIMITED,
|
||||
)
|
||||
|
||||
private fun approvalTxUncompiled(): TransactionData.Uncompiled = TransactionData.Uncompiled(
|
||||
amount = realAmount(),
|
||||
fee = null,
|
||||
sourceAddress = "0xFrom",
|
||||
destinationAddress = "0xContract",
|
||||
)
|
||||
|
||||
private fun swapTxUncompiled(): TransactionData.Uncompiled = TransactionData.Uncompiled(
|
||||
amount = realAmount(),
|
||||
fee = NORMAL_FEE,
|
||||
sourceAddress = "0xFrom",
|
||||
destinationAddress = "0xTo",
|
||||
)
|
||||
|
||||
private fun realAmount(): Amount = Amount(
|
||||
currencySymbol = "ETH",
|
||||
value = BigDecimal.ONE,
|
||||
decimals = 18,
|
||||
)
|
||||
|
||||
private fun singleFee(): TransactionFee.Single = TransactionFee.Single(normal = NORMAL_FEE)
|
||||
|
||||
private fun choosableFee(): TransactionFee.Choosable = TransactionFee.Choosable(
|
||||
minimum = MIN_FEE,
|
||||
normal = NORMAL_FEE,
|
||||
priority = PRIORITY_FEE,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
|
||||
private suspend fun onSwap(provider: ExchangeProviderType, swapData: SwapDataModel?) {
|
||||
|
|
@ -177,10 +354,28 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
|
|||
|
||||
private fun coVerifyCreateTransaction(times: Int) = coVerify(exactly = times) {
|
||||
createTransactionUseCase(
|
||||
amount = any(), fee = any(), memo = any(),
|
||||
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
memo = any(),
|
||||
destination = any(),
|
||||
userWalletId = any(),
|
||||
network = any(),
|
||||
txExtras = any(),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private companion object {
|
||||
const val APPROVAL_HASH = "0xApprovalHash"
|
||||
const val SWAP_HASH = "0xSwapHash"
|
||||
|
||||
val MIN_FEE: Fee = feeOf(BigDecimal("0.001"))
|
||||
val NORMAL_FEE: Fee = feeOf(BigDecimal("0.002"))
|
||||
val PRIORITY_FEE: Fee = feeOf(BigDecimal("0.003"))
|
||||
|
||||
private fun feeOf(value: BigDecimal): Fee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "ETH", value = value, decimals = 18),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,6 +88,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
|
||||
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
|
||||
protected val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk(relaxed = true)
|
||||
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||
|
||||
// endregion
|
||||
|
||||
|
|
@ -121,6 +123,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
cexSwapFeeCalculator = cexSwapFeeCalculator,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
yieldModuleAddressProvider = yieldModuleAddressProvider,
|
||||
createApprovalTransactionUseCase = createApprovalTransactionUseCase,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ internal class CexSwapFeeCalculatorTest {
|
|||
// None of the fee use cases were invoked
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeUseCase.invoke(any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
)
|
||||
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
|
@ -116,7 +121,12 @@ internal class CexSwapFeeCalculatorTest {
|
|||
// Other use cases are NOT called.
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeUseCase.invoke(any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +164,12 @@ internal class CexSwapFeeCalculatorTest {
|
|||
}
|
||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
estimateFeeForTokenUseCase(any(), any(), any(), any())
|
||||
estimateFeeForTokenUseCase(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
)
|
||||
} returns expected.right()
|
||||
|
||||
val result = sut.calculate(
|
||||
|
|
@ -228,39 +243,44 @@ internal class CexSwapFeeCalculatorTest {
|
|||
)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
)
|
||||
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
val rawFee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
|
||||
)
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns TransactionFee.Single(normal = rawFee).right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = coinStatus, isGasless = true,
|
||||
)
|
||||
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
|
||||
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
|
||||
assertThat(unchanged).isSameInstanceAs(rawFee)
|
||||
}
|
||||
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
val rawFee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
|
||||
)
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns TransactionFee.Single(normal = rawFee).right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = coinStatus,
|
||||
isGasless = true,
|
||||
)
|
||||
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
|
||||
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
|
||||
assertThat(unchanged).isSameInstanceAs(rawFee)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.blockchain.common.TransactionExtras
|
|||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
|
|
@ -19,21 +20,14 @@ import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
|
|||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
|
||||
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -137,7 +131,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { assertThat(it).isEqualTo(ExpressDataError.UnknownError()) }
|
||||
result.onLeft { assertThat(it).isEqualTo(GetFeeError.UnknownError) }
|
||||
// getFeeUseCase should not have been called because balance check short-circuits first.
|
||||
// Use a more permissive verify to avoid clashing with the other overload signatures.
|
||||
coVerify(exactly = 0) {
|
||||
|
|
@ -256,7 +250,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(ExpressDataError.UnknownError())
|
||||
assertThat(error).isEqualTo(GetFeeError.UnknownError)
|
||||
}
|
||||
// Fallback use-case must NOT be invoked when gas is null — there's nothing to feed it.
|
||||
coVerify(exactly = 0) {
|
||||
|
|
@ -269,6 +263,221 @@ internal class DexSwapFeeCalculatorTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeForTokenUseCase returns Left`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val selectedToken = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0xToken",
|
||||
isCoin = false,
|
||||
).status
|
||||
val gas = BigInteger.valueOf(99_000L)
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
|
||||
|
||||
coEvery {
|
||||
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
|
||||
|
||||
// The token branch normally yields LoadedExtended, but on Left we fall back to the
|
||||
// eth-specific Loaded fee — mirroring the exception path.
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { dexFeeResult ->
|
||||
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.Loaded::class.java)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap surfaces error when getFeeForTokenUseCase fails and transaction gas is null`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val selectedToken = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0xToken",
|
||||
isCoin = false,
|
||||
).status
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = null)
|
||||
|
||||
coEvery {
|
||||
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
|
||||
|
||||
// gas is null → the original left error is surfaced, the fallback use case is not invoked.
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(GetFeeError.UnknownError)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap raises DataError when exception path is hit and transaction gas is null`() = runTest {
|
||||
// The exception (catch) branch wraps the thrown Throwable as GetFeeError.DataError when gas
|
||||
// is null — distinct from the Either.Left branches, which surface the original Left error.
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
// txValue == null forces error("unable to get txValue") inside the catch block.
|
||||
val transaction = buildDex(txValue = null, gas = null)
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap propagates fallback error when getFeeUseCase Left and getEthSpecificFeeUseCase also Left`() =
|
||||
runTest {
|
||||
// Both the primary fee call and the eth-specific fallback fail. The fallback uses .bind(),
|
||||
// so its Left error must be surfaced verbatim.
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val gas = BigInteger.valueOf(80_000L)
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
|
||||
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
val fallbackError = GetFeeError.DataError(IllegalStateException("eth specific failed"))
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns fallbackError.left()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(fallbackError)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap token branch returns LoadedExtended on success and does not call fallback`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val selectedToken = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0xToken",
|
||||
isCoin = false,
|
||||
).status
|
||||
val transaction = buildDex(txValue = "1000000000000000")
|
||||
|
||||
coEvery {
|
||||
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
|
||||
} returns mockk<TransactionFeeExtended>(relaxed = true).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { dexFeeResult ->
|
||||
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
|
||||
assertThat(dexFeeResult.gas).isEqualTo(transaction.gas)
|
||||
}
|
||||
// On the happy token path neither the eth-specific fallback nor the native getFeeUseCase fires.
|
||||
coVerify(exactly = 0) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap token branch falls back to getEthSpecificFeeUseCase when exception path is hit`() = runTest {
|
||||
// selectedToken is a Token, but createTransactionExtrasUseCase fails before the token branch is
|
||||
// reached, so the exception catch fires. With gas present the eth-specific fallback applies.
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val selectedToken = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0xToken",
|
||||
isCoin = false,
|
||||
).status
|
||||
val gas = BigInteger.valueOf(123_000L)
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
|
||||
|
||||
every {
|
||||
createTransactionExtrasUseCase.invoke(data = any(), network = any())
|
||||
} returns IllegalStateException("forced fail").left()
|
||||
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction, selectedToken = selectedToken)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { dexFeeResult ->
|
||||
// Fallback always yields Loaded, never LoadedExtended, even on the token branch.
|
||||
assertThat(dexFeeResult.transactionFee).isInstanceOf(TransactionFeeResult.Loaded::class.java)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
// The token use case is never reached because extras creation throws first.
|
||||
coVerify(exactly = 0) {
|
||||
getFeeForTokenUseCase.invoke(userWallet = any(), token = any(), transactionData = any())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 12% gas patch — golden numbers
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -367,7 +576,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError())
|
||||
assertThat(error).isEqualTo(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
|
||||
}
|
||||
// No fee is computed when the size guard trips
|
||||
coVerify(exactly = 0) {
|
||||
|
|
@ -375,6 +584,140 @@ internal class DexSwapFeeCalculatorTest {
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integrated-approve simulated estimation override ([REDACTED_TASK_KEY])
|
||||
//
|
||||
// The end-to-end EstimateOverrideError → legacy-fallback recompute is exercised at the
|
||||
// interactor level in
|
||||
// [com.tangem.feature.swap.domain.SwapInteractorImplLoadSwapFeeTest] (which owns the
|
||||
// session-fallback state machine). Here we only assert the calculator's branch selection:
|
||||
// PermissionSettings → simulated estimation; Empty → plain getFee path.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap with PermissionSettings uses the simulated estimation path`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(txValue = "1000000000000000")
|
||||
val permissionState = PermissionDataState.PermissionSettings(
|
||||
type = ApproveType.LIMITED,
|
||||
spenderAddress = "0xSpender",
|
||||
)
|
||||
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
|
||||
|
||||
sut.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
permissionState = permissionState,
|
||||
)
|
||||
|
||||
// PermissionSettings must drive the simulated estimation (isSimulateEstimation = true) with
|
||||
// the spender carried through from the permission state.
|
||||
coVerify(exactly = 1) {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any(),
|
||||
spenderAddress = "0xSpender",
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap with Empty permission uses plain getFee path and does not simulate`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(txValue = "1000000000000000")
|
||||
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulateEstimation = false,
|
||||
)
|
||||
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
|
||||
|
||||
val result = sut.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
// The simulated estimation must not be used when there is no PermissionSettings context.
|
||||
coVerify(exactly = 0) {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap raises EstimateOverrideError without eth-specific fallback even when gas is present`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
// gas is present — the legacy fallback would normally kick in for a plain Left,
|
||||
// but an EstimateOverrideError must be raised verbatim so the model can trigger
|
||||
// the integrated-approval fallback instead of silently using the eth-specific fee.
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = BigInteger.valueOf(50_000L))
|
||||
val permissionState = PermissionDataState.PermissionSettings(
|
||||
type = ApproveType.LIMITED,
|
||||
spenderAddress = "0xSpender",
|
||||
)
|
||||
val overrideError = GetFeeError.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
error = "execution reverted",
|
||||
)
|
||||
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
} returns overrideError.left()
|
||||
|
||||
val result = sut.calculate(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
transaction = transaction,
|
||||
permissionState = permissionState,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(overrideError)
|
||||
}
|
||||
// The eth-specific fallback must NOT be invoked for EstimateOverrideError, even though
|
||||
// gas is present — otherwise the model would never see the override and the
|
||||
// integrated-approval fallback would not trigger.
|
||||
coVerify(exactly = 0) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// otherNativeFee propagation (bridge protocol fee)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
|
|||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
|
||||
|
|
@ -12,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
|
|||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
|
||||
import com.tangem.core.analytics.models.getReferralParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
|
@ -340,6 +342,21 @@ sealed class SwapEvents(
|
|||
"Network fee" to feeNetwork.name,
|
||||
),
|
||||
), AppsFlyerIncludedEvent
|
||||
|
||||
class ApproveGasOverrideError(
|
||||
fromTokenSymbol: String,
|
||||
fromTokenBlockchain: String,
|
||||
rpcProvider: String,
|
||||
error: String,
|
||||
) : SwapEvents(
|
||||
event = "Gas Estimation Override Error",
|
||||
params = mapOf(
|
||||
TOKEN_PARAM to fromTokenSymbol,
|
||||
BLOCKCHAIN to fromTokenBlockchain,
|
||||
"RPC Provider" to rpcProvider,
|
||||
ERROR_MESSAGE to error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import arrow.core.Either
|
||||
import arrow.core.flatMap
|
||||
import arrow.core.getOrElse
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
|
|
@ -1270,6 +1271,7 @@ internal class SwapModel @Inject constructor(
|
|||
fee = swapFee,
|
||||
expressOperationType = ExpressOperationType.SWAP,
|
||||
isTangemPayWithdrawal = isTangemPayWithdrawal,
|
||||
integratedApproval = lastLoadedQuotesState.integratedApprovalData,
|
||||
)
|
||||
}.onSuccess { swapTransactionState ->
|
||||
when (swapTransactionState) {
|
||||
|
|
@ -2302,12 +2304,13 @@ internal class SwapModel @Inject constructor(
|
|||
val toSwapCurrencyStatus =
|
||||
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
|
||||
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
|
||||
|
||||
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrencyStatus.currency,
|
||||
toSwapCurrencyStatus.currency,
|
||||
)
|
||||
if (shouldTransferInsteadOfSwap) {
|
||||
return swapTransferInteractor.loadFee(
|
||||
return if (shouldTransferInsteadOfSwap) {
|
||||
swapTransferInteractor.loadFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
fromTokenAmount = amount,
|
||||
|
|
@ -2316,7 +2319,20 @@ internal class SwapModel @Inject constructor(
|
|||
}.onRight {
|
||||
TangemLogger.e("loadFee[transfer]: Fee loaded successfully")
|
||||
}
|
||||
} else {
|
||||
loadSwapModeFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amount = amount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSwapModeFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: BigDecimal,
|
||||
): Either<GetFeeError, TransactionFee> {
|
||||
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
|
||||
|
||||
if (isPermissionNotificationShown()) {
|
||||
|
|
@ -2331,8 +2347,12 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
ExchangeProviderType.CEX -> null
|
||||
}
|
||||
val integratedSettings = (quoteState.permissionState as? PermissionDataState.PermissionSettings)
|
||||
?.takeIf { swapFeatureToggles.isSwapIntegratedApproveEnabled }
|
||||
|
||||
// Get swap tx fee
|
||||
return swapInteractor.loadSwapFee(
|
||||
provider = quoteState.swapProvider,
|
||||
quotesLoadedState = quoteState,
|
||||
fromStatus = fromSwapCurrencyStatus,
|
||||
toStatus = toSwapCurrencyStatus,
|
||||
amount = swapAmount,
|
||||
|
|
@ -2346,6 +2366,19 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}.onLeft {
|
||||
TangemLogger.e("loadFee: Failed to load fee with error $it")
|
||||
}.flatMap { swapTxFee ->
|
||||
if (integratedSettings != null) {
|
||||
// Get fee & tx data for integrated approval case
|
||||
loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
quoteState = quoteState,
|
||||
permissionSettings = integratedSettings,
|
||||
approvalAmount = amount,
|
||||
swapTxFee = swapTxFee,
|
||||
)
|
||||
} else {
|
||||
Either.Right(swapTxFee)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2369,42 +2402,43 @@ internal class SwapModel @Inject constructor(
|
|||
fromTokenAmount = amount,
|
||||
selectedToken = selectedToken,
|
||||
)
|
||||
}
|
||||
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
|
||||
} else {
|
||||
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
|
||||
|
||||
if (isPermissionNotificationShown()) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
|
||||
|
||||
// DEX path requires a SwapDataModel.
|
||||
val swapDataForCall = when (quoteState.swapProvider.type) {
|
||||
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
|
||||
// TODO support gasless in DEX/DEX_BRIDGE
|
||||
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
|
||||
if (isPermissionNotificationShown()) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
ExchangeProviderType.CEX -> null
|
||||
}
|
||||
|
||||
return swapInteractor.loadSwapFee(
|
||||
provider = quoteState.swapProvider,
|
||||
fromStatus = fromSwapCurrencyStatus,
|
||||
toStatus = toSwapCurrencyStatus,
|
||||
amount = swapAmount,
|
||||
swapData = swapDataForCall,
|
||||
selectedFeeToken = selectedToken,
|
||||
isGasless = true,
|
||||
).map { swapFee ->
|
||||
// The fee selector block consumes TransactionFeeExtended; build one when
|
||||
// `transactionFeeResult` is LoadedExtended, else wrap the native fee in a
|
||||
// pass-through TransactionFeeExtended for compatibility with the block API.
|
||||
when (val res = swapFee.transactionFeeResult) {
|
||||
is TransactionFeeResult.LoadedExtended -> res.fee
|
||||
is TransactionFeeResult.Loaded -> TransactionFeeExtended(
|
||||
transactionFee = res.fee,
|
||||
feeTokenId = swapFee.selectedFeeToken.currency.id,
|
||||
)
|
||||
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
|
||||
|
||||
// DEX path requires a SwapDataModel.
|
||||
val swapDataForCall = when (quoteState.swapProvider.type) {
|
||||
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
|
||||
// TODO support gasless in DEX/DEX_BRIDGE
|
||||
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
|
||||
}
|
||||
ExchangeProviderType.CEX -> null
|
||||
}
|
||||
|
||||
return swapInteractor.loadSwapFee(
|
||||
quotesLoadedState = quoteState,
|
||||
fromStatus = fromSwapCurrencyStatus,
|
||||
toStatus = toSwapCurrencyStatus,
|
||||
amount = swapAmount,
|
||||
swapData = swapDataForCall,
|
||||
selectedFeeToken = selectedToken,
|
||||
isGasless = true,
|
||||
).map { swapFee ->
|
||||
// The fee selector block consumes TransactionFeeExtended; build one when
|
||||
// `transactionFeeResult` is LoadedExtended, else wrap the native fee in a
|
||||
// pass-through TransactionFeeExtended for compatibility with the block API.
|
||||
when (val res = swapFee.transactionFeeResult) {
|
||||
is TransactionFeeResult.LoadedExtended -> res.fee
|
||||
is TransactionFeeResult.Loaded -> TransactionFeeExtended(
|
||||
transactionFee = res.fee,
|
||||
feeTokenId = swapFee.selectedFeeToken.currency.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2412,60 +2446,56 @@ internal class SwapModel @Inject constructor(
|
|||
override fun onResult(newState: FeeSelectorUM) {
|
||||
state.value = newState
|
||||
|
||||
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
|
||||
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return
|
||||
|
||||
if (newState is FeeSelectorUM.Error) {
|
||||
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
|
||||
refreshTransferUIStateIfNeeded()
|
||||
uiState = stateBuilder.createFeeErrorState(
|
||||
uiStateHolder = uiState,
|
||||
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
|
||||
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
|
||||
feeError = newState.error,
|
||||
handleFeeError(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
feeError = newState,
|
||||
)
|
||||
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
|
||||
return
|
||||
}
|
||||
|
||||
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
|
||||
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
|
||||
// Transfer mode has its own fee pipeline and doesn't use swap quotes.
|
||||
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
)
|
||||
if (shouldTransferInsteadOfSwap) {
|
||||
refreshTransferUIStateIfNeeded(
|
||||
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
|
||||
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
|
||||
} else {
|
||||
// Transfer mode has its own fee pipeline and doesn't use swap quotes.
|
||||
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrencyStatus.currency,
|
||||
toSwapCurrencyStatus.currency,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
|
||||
val swapFee = getSelectedSwapFee() ?: return
|
||||
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val patchedState = swapInteractor.applySwapFee(
|
||||
state = quoteState,
|
||||
fee = swapFee,
|
||||
lastReducedBalanceBy = lastReducedBalanceBy.value,
|
||||
)
|
||||
val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
|
||||
put(quoteState.swapProvider, patchedState)
|
||||
if (shouldTransferInsteadOfSwap) {
|
||||
refreshTransferUIStateIfNeeded(
|
||||
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
|
||||
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
|
||||
)
|
||||
return
|
||||
}
|
||||
withContext(dispatchers.main) {
|
||||
dataState = dataState.copy(
|
||||
lastLoadedSwapStates = patchedStates,
|
||||
feePaidCryptoCurrency = swapFee.selectedFeeToken,
|
||||
)
|
||||
// Refresh UI via the existing pipeline.
|
||||
val updatedFromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@withContext
|
||||
val updatedToSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return@withContext
|
||||
setupLoadedState(
|
||||
provider = quoteState.swapProvider,
|
||||
state = patchedState,
|
||||
fromSwapCurrencyStatus = updatedFromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = updatedToSwapCurrencyStatus,
|
||||
|
||||
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
|
||||
val swapFee = getSelectedSwapFee() ?: return
|
||||
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val patchedState = swapInteractor.applySwapFee(
|
||||
state = quoteState,
|
||||
fee = swapFee,
|
||||
lastReducedBalanceBy = lastReducedBalanceBy.value,
|
||||
)
|
||||
val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
|
||||
put(quoteState.swapProvider, patchedState)
|
||||
}
|
||||
withContext(dispatchers.main) {
|
||||
dataState = dataState.copy(
|
||||
lastLoadedSwapStates = patchedStates,
|
||||
feePaidCryptoCurrency = swapFee.selectedFeeToken,
|
||||
)
|
||||
// Refresh UI via the existing pipeline.
|
||||
val updatedFromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@withContext
|
||||
val updatedToSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return@withContext
|
||||
setupLoadedState(
|
||||
provider = quoteState.swapProvider,
|
||||
state = patchedState,
|
||||
fromSwapCurrencyStatus = updatedFromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = updatedToSwapCurrencyStatus,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2481,7 +2511,139 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private fun isPermissionNotificationShown(): Boolean {
|
||||
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
|
||||
return permissionState != null && permissionState !is PermissionDataState.Empty
|
||||
val isApprovalIntegrated = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
|
||||
permissionState is PermissionDataState.PermissionSettings
|
||||
return permissionState != null && permissionState !is PermissionDataState.Empty && !isApprovalIntegrated
|
||||
}
|
||||
|
||||
private fun handleFeeError(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
feeError: FeeSelectorUM.Error,
|
||||
) {
|
||||
val error = feeError.error
|
||||
if (error is GetFeeError.EstimateOverrideError) {
|
||||
analyticsEventHandler.send(
|
||||
SwapEvents.ApproveGasOverrideError(
|
||||
fromTokenSymbol = error.tokenSymbol,
|
||||
fromTokenBlockchain = error.blockchain,
|
||||
rpcProvider = error.rpcProvider,
|
||||
error = error.error,
|
||||
),
|
||||
)
|
||||
val (provider, swapState) = updateLoadedQuotes(
|
||||
dataState.lastLoadedSwapStates.mapValues { (_, state) ->
|
||||
if (state is SwapState.QuotesLoadedState) {
|
||||
val permissionState = state.permissionState
|
||||
if (permissionState is PermissionDataState.PermissionSettings) {
|
||||
swapInteractor.integratedApprovalFallback(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
spenderAddress = permissionState.spenderAddress,
|
||||
)
|
||||
state.copy(
|
||||
integratedApprovalData = null,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = permissionState.spenderAddress,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
} else {
|
||||
state
|
||||
}
|
||||
},
|
||||
)
|
||||
setupLoadedState(
|
||||
provider = provider,
|
||||
state = swapState,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
)
|
||||
} else {
|
||||
TangemLogger.e("loadFee: ${feeError.error}, isHidden = true")
|
||||
refreshTransferUIStateIfNeeded()
|
||||
uiState = stateBuilder.createFeeErrorState(
|
||||
uiStateHolder = uiState,
|
||||
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
|
||||
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
|
||||
feeError = feeError.error,
|
||||
)
|
||||
modelScope.launch { forceUpdateState.emit(feeError.copy(isHidden = true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the approval transaction + its fee, stores both on the current
|
||||
* [SwapState.QuotesLoadedState] as [IntegratedApprovalData], and returns the *combined*
|
||||
* [TransactionFee] (approve + swap, per bucket) for the fee selector to render.
|
||||
*
|
||||
* The user sees a single fee number that already includes the approval cost. At submission
|
||||
* time `onSwapClick` reads the stored [IntegratedApprovalData] back from
|
||||
* `lastLoadedSwapStates` and sends both txs in a single DEFAULT-mode batch.
|
||||
*/
|
||||
private suspend fun loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
quoteState: SwapState.QuotesLoadedState,
|
||||
permissionSettings: PermissionDataState.PermissionSettings,
|
||||
approvalAmount: BigDecimal,
|
||||
swapTxFee: TransactionFee,
|
||||
): Either<GetFeeError, TransactionFee> {
|
||||
return swapInteractor.loadIntegratedApprovalData(
|
||||
fromStatus = fromSwapCurrencyStatus,
|
||||
spenderAddress = permissionSettings.spenderAddress,
|
||||
approveType = permissionSettings.type,
|
||||
approvalAmount = approvalAmount,
|
||||
).onLeft {
|
||||
TangemLogger.e("loadAndStoreIntegratedApproval: failed: $it")
|
||||
}.map { integratedApprovalData ->
|
||||
val selectedProvider = quoteState.swapProvider
|
||||
val updatedState = quoteState.copy(integratedApprovalData = integratedApprovalData)
|
||||
dataState = dataState.copy(
|
||||
lastLoadedSwapStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
|
||||
put(selectedProvider, updatedState)
|
||||
},
|
||||
)
|
||||
combineTransactionFees(integratedApprovalData.approvalFee, swapTxFee)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket sum of two EVM [TransactionFee]s — used to present the integrated
|
||||
* approve+swap total to the user. Mirrors `GiveApprovalModel.estimateFeeForResetApproval`'s
|
||||
* sum strategy (same gas-price, summed gas-limit). Non-EVM fees fall back to the swap fee
|
||||
* alone since the integrated path is currently EVM-only (DEX, non-Solana).
|
||||
*/
|
||||
private fun combineTransactionFees(approvalFee: TransactionFee, swapFee: TransactionFee): TransactionFee {
|
||||
return when {
|
||||
approvalFee is TransactionFee.Choosable && swapFee is TransactionFee.Choosable ->
|
||||
TransactionFee.Choosable(
|
||||
minimum = sumEvmFees(approvalFee.minimum, swapFee.minimum),
|
||||
normal = sumEvmFees(approvalFee.normal, swapFee.normal),
|
||||
priority = sumEvmFees(approvalFee.priority, swapFee.priority),
|
||||
)
|
||||
else -> TransactionFee.Single(normal = sumEvmFees(approvalFee.normal, swapFee.normal))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums two [Fee.Ethereum] fees as approval + swap. Adds gas limits (same gas price) and
|
||||
* recomputes the on-chain amount. For non-Ethereum fees returns [right] unchanged — the
|
||||
* integrated approve+swap path is EVM-only today.
|
||||
*/
|
||||
private fun sumEvmFees(left: Fee, right: Fee): Fee {
|
||||
if (left !is Fee.Ethereum || right !is Fee.Ethereum) return right
|
||||
val leftValue = left.amount.value ?: return right
|
||||
val rightValue = right.amount.value ?: return right
|
||||
val combinedValue = leftValue + rightValue
|
||||
val combinedGasLimit = left.gasLimit + right.gasLimit
|
||||
val combinedAmount = right.amount.copy(value = combinedValue)
|
||||
return when (right) {
|
||||
is Fee.Ethereum.EIP1559 -> right.copy(amount = combinedAmount, gasLimit = combinedGasLimit)
|
||||
is Fee.Ethereum.Legacy -> right.copy(amount = combinedAmount, gasLimit = combinedGasLimit)
|
||||
is Fee.Ethereum.TokenCurrency -> right
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,15 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
|
||||
when (feeError) {
|
||||
is GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError -> {
|
||||
add(
|
||||
getWarningForError(
|
||||
expressDataError = ExpressDataError.TooLargeSolanaTransactionError(),
|
||||
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
onRetryClick = actions.onRetryClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
is GetFeeError.DataError -> {
|
||||
val error = feeError.cause
|
||||
if (error is ExpressDataError) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel]'s integrated approve+swap fee combination (private `combineTransactionFees`
|
||||
* / `sumEvmFees`). These are pure functions, so they are exercised via reflection (the public
|
||||
* fee-loading pipeline that calls them requires a large amount of async wiring; the plan permits
|
||||
* reflection for pure private functions where the public path is brittle).
|
||||
*
|
||||
* Verifies:
|
||||
* - Choosable + Choosable → per-bucket [TransactionFee.Choosable] with summed amount + gasLimit.
|
||||
* - Single involved (either side) → [TransactionFee.Single] summing the `normal` fees.
|
||||
* - Legacy EVM fees are summed too (amount + gasLimit).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelCombineFeesTest : SwapModelTestBase() {
|
||||
|
||||
private lateinit var model: SwapModel
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
model = createModel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable plus Choosable THEN per-bucket sum of amount and gasLimit`() {
|
||||
val approval = choosable(min = 1, normal = 2, priority = 3, gas = 21_000)
|
||||
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Choosable::class.java)
|
||||
val choosable = result as TransactionFee.Choosable
|
||||
assertEip1559(choosable.minimum, expectedValue = 11, expectedGas = 71_000)
|
||||
assertEip1559(choosable.normal, expectedValue = 22, expectedGas = 71_000)
|
||||
assertEip1559(choosable.priority, expectedValue = 33, expectedGas = 71_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Single approval and Choosable swap THEN result is Single summing normals`() {
|
||||
val approval = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000))
|
||||
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
|
||||
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both Single THEN result is Single summing normals`() {
|
||||
val approval = TransactionFee.Single(normal = eip1559(value = 5, gas = 21_000))
|
||||
val swap = TransactionFee.Single(normal = eip1559(value = 7, gas = 30_000))
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
|
||||
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 12, expectedGas = 51_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Legacy EVM fees THEN summed amount and gasLimit`() {
|
||||
val approval = TransactionFee.Single(normal = legacy(value = 2, gas = 21_000))
|
||||
val swap = TransactionFee.Single(normal = legacy(value = 20, gas = 50_000))
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
val normal = (result as TransactionFee.Single).normal
|
||||
assertThat(normal).isInstanceOf(Fee.Ethereum.Legacy::class.java)
|
||||
val legacy = normal as Fee.Ethereum.Legacy
|
||||
assertThat(legacy.amount.value).isEqualTo(BigDecimal(22))
|
||||
assertThat(legacy.gasLimit).isEqualTo(BigInteger.valueOf(71_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadAndStoreIntegratedApproval stores IntegratedApprovalData on the quote state and returns combined fee`() =
|
||||
runTest {
|
||||
val provider = swapProvider()
|
||||
val quoteState = quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.UNLIMITED, spender = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(provider to quoteState),
|
||||
)
|
||||
val approvalData = IntegratedApprovalData(
|
||||
approvalTransaction = mockk<TransactionData.Uncompiled>(relaxed = true),
|
||||
approvalFee = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000)),
|
||||
approveType = ApproveType.UNLIMITED,
|
||||
)
|
||||
coEvery {
|
||||
swapInteractor.loadIntegratedApprovalData(
|
||||
fromStatus = any(),
|
||||
spenderAddress = any(),
|
||||
approveType = any(),
|
||||
approvalAmount = any(),
|
||||
)
|
||||
} returns approvalData.right()
|
||||
|
||||
val combined = loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||
quoteState = quoteState,
|
||||
permissionSettings = permissionSettings(
|
||||
type = ApproveType.UNLIMITED,
|
||||
spender = "0xSpender",
|
||||
),
|
||||
approvalAmount = BigDecimal.ONE,
|
||||
swapTxFee = TransactionFee.Single(normal = eip1559(value = 20, gas = 50_000)),
|
||||
)
|
||||
|
||||
assertThat(combined.isRight()).isTrue()
|
||||
combined.onRight { fee ->
|
||||
assertEip1559((fee as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
|
||||
}
|
||||
// Stored on the current loaded state for later submission.
|
||||
val stored = (model.dataState.lastLoadedSwapStates[provider] as SwapState.QuotesLoadedState)
|
||||
.integratedApprovalData
|
||||
assertThat(stored).isEqualTo(approvalData)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private suspend fun loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus: com.tangem.domain.swap.models.SwapCurrencyStatus,
|
||||
quoteState: SwapState.QuotesLoadedState,
|
||||
permissionSettings: PermissionDataState.PermissionSettings,
|
||||
approvalAmount: BigDecimal,
|
||||
swapTxFee: TransactionFee,
|
||||
): arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee> {
|
||||
val method = SwapModel::class.java.declaredMethods.first { it.name == "loadAndStoreIntegratedApproval" }
|
||||
.apply { isAccessible = true }
|
||||
return invokeSuspend(method, fromSwapCurrencyStatus, quoteState, permissionSettings, approvalAmount, swapTxFee)
|
||||
as arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee>
|
||||
}
|
||||
|
||||
private suspend fun invokeSuspend(method: java.lang.reflect.Method, vararg args: Any?): Any? =
|
||||
kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn { cont ->
|
||||
method.invoke(model, *args, cont)
|
||||
}
|
||||
|
||||
private fun combineTransactionFees(approvalFee: TransactionFee, swapFee: TransactionFee): TransactionFee {
|
||||
val method = SwapModel::class.java.getDeclaredMethod(
|
||||
"combineTransactionFees",
|
||||
TransactionFee::class.java,
|
||||
TransactionFee::class.java,
|
||||
).apply { isAccessible = true }
|
||||
return method.invoke(model, approvalFee, swapFee) as TransactionFee
|
||||
}
|
||||
|
||||
private fun choosable(min: Int, normal: Int, priority: Int, gas: Long): TransactionFee.Choosable =
|
||||
TransactionFee.Choosable(
|
||||
minimum = eip1559(value = min, gas = gas),
|
||||
normal = eip1559(value = normal, gas = gas),
|
||||
priority = eip1559(value = priority, gas = gas),
|
||||
)
|
||||
|
||||
private fun eip1559(value: Int, gas: Long): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559(
|
||||
amount = ethAmount(value),
|
||||
gasLimit = BigInteger.valueOf(gas),
|
||||
maxFeePerGas = BigInteger.ONE,
|
||||
priorityFee = BigInteger.ONE,
|
||||
)
|
||||
|
||||
private fun legacy(value: Int, gas: Long): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(value),
|
||||
gasLimit = BigInteger.valueOf(gas),
|
||||
gasPrice = BigInteger.ONE,
|
||||
)
|
||||
|
||||
private fun ethAmount(value: Int): Amount = Amount(
|
||||
currencySymbol = "ETH",
|
||||
value = BigDecimal(value),
|
||||
decimals = 18,
|
||||
)
|
||||
|
||||
private fun assertEip1559(fee: Fee, expectedValue: Int, expectedGas: Long) {
|
||||
assertThat(fee).isInstanceOf(Fee.Ethereum.EIP1559::class.java)
|
||||
val eip = fee as Fee.Ethereum.EIP1559
|
||||
assertThat(eip.amount.value).isEqualTo(BigDecimal(expectedValue))
|
||||
assertThat(eip.gasLimit).isEqualTo(BigInteger.valueOf(expectedGas))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.analytics.SwapEvents
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel]'s integrated-approval fallback trigger
|
||||
* (`SwapModel.FeeSelectorRepository.onResult` → private `handleFeeError`).
|
||||
*
|
||||
* Driven through the public `feeSelectorRepository.onResult(FeeSelectorUM.Error(...))` path:
|
||||
*
|
||||
* (a) `EstimateOverrideError` + `PermissionSettings` → `swapInteractor.integratedApprovalFallback`
|
||||
* is called once with the matching spender, and the loaded state is rewritten to
|
||||
* `PermissionRequired(isResetApproval = false)` with `integratedApprovalData == null`.
|
||||
* (b) `EstimateOverrideError` + non-`PermissionSettings` permission → no fallback call, state
|
||||
* left as-is (permission stays Empty).
|
||||
* (c) non-`EstimateOverrideError` (plain fee error) → no fallback call (plain fee-error path).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelHandleFeeErrorTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError and PermissionSettings THEN fallback is triggered and state becomes PermissionRequired`() =
|
||||
runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
swapInteractor.integratedApprovalFallback(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
)
|
||||
}
|
||||
// The gas-override analytics event must be reported once, carrying the error fields.
|
||||
verify(exactly = 1) {
|
||||
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
|
||||
}
|
||||
val sentEvents = mutableListOf<AnalyticsEvent>()
|
||||
verify { analyticsEventHandler.send(capture(sentEvents)) }
|
||||
val overrideEvent = sentEvents.filterIsInstance<SwapEvents.ApproveGasOverrideError>().single()
|
||||
assertThat(overrideEvent.params).isEqualTo(
|
||||
mapOf(
|
||||
"Token" to "USDT",
|
||||
"Blockchain" to "ethereum",
|
||||
"RPC Provider" to "infura",
|
||||
"Error Message" to "execution reverted",
|
||||
),
|
||||
)
|
||||
val updated = model.dataState.getCurrentLoadedSwapState()
|
||||
val permission = updated?.permissionState as? PermissionDataState.PermissionRequired
|
||||
assertThat(permission).isNotNull()
|
||||
assertThat(permission!!.isResetApproval).isFalse()
|
||||
assertThat(permission.spenderAddress).isEqualTo(SPENDER)
|
||||
assertThat(updated.integratedApprovalData).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError and non-PermissionSettings THEN no fallback call`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider = provider, permissionState = PermissionDataState.Empty),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
|
||||
}
|
||||
// Permission untouched.
|
||||
assertThat(model.dataState.getCurrentLoadedSwapState()?.permissionState)
|
||||
.isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-EstimateOverrideError THEN no fallback call (plain fee-error path)`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
// The plain path runs the model's StateBuilder/refresh; relaxed mocks cover it.
|
||||
// We assert only the absence of the fallback call.
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = GetFeeError.UnknownError, isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
|
||||
}
|
||||
// The gas-override analytics event belongs only to the EstimateOverrideError branch.
|
||||
verify(exactly = 0) {
|
||||
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
|
||||
}
|
||||
}
|
||||
|
||||
private fun estimateOverrideError() = GetFeeError.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
error = "execution reverted",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import com.tangem.feature.swap.domain.AllowPermissionsHandler
|
|||
import com.tangem.feature.swap.domain.SwapInteractor
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
|
||||
|
|
@ -198,9 +199,15 @@ internal abstract class SwapModelTestBase {
|
|||
protected fun quotesLoadedState(
|
||||
provider: SwapProvider,
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
integratedApprovalData: IntegratedApprovalData? = null,
|
||||
): SwapState.QuotesLoadedState = mockk(relaxed = true) {
|
||||
every { swapProvider } returns provider
|
||||
every { this@mockk.permissionState } returns permissionState
|
||||
every { this@mockk.integratedApprovalData } returns integratedApprovalData
|
||||
// Matcher for the copy(...) overload `handleFeeError` uses on the integrated-approval
|
||||
// fallback path: it copies `integratedApprovalData` (→ null) and `permissionState`
|
||||
// (→ PermissionRequired). Includes `integratedApprovalData` so MockK matches that call
|
||||
// and the rebuilt mock reflects the new permissionState / integratedApprovalData.
|
||||
every {
|
||||
copy(
|
||||
fromTokenInfo = any(),
|
||||
|
|
@ -213,11 +220,17 @@ internal abstract class SwapModelTestBase {
|
|||
validationResult = any(),
|
||||
minAdaValue = any(),
|
||||
swapProvider = any(),
|
||||
integratedApprovalData = any(),
|
||||
)
|
||||
} answers {
|
||||
// `copy` arg indices follow the QuotesLoadedState primary-constructor order:
|
||||
// 0 fromTokenInfo, 1 toTokenInfo, 2 swapProvider, 3 priceImpact,
|
||||
// 4 preparedSwapConfigState, 5 permissionState, 6 swapDataModel,
|
||||
// 7 integratedApprovalData, 8 currencyCheck, 9 validationResult, 10 minAdaValue.
|
||||
quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = arg(4),
|
||||
permissionState = arg(5),
|
||||
integratedApprovalData = arg(7),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
# https://github.com/tangem/tangem-sdk-android/
|
||||
# https://github.com/tangem/vico
|
||||
|
||||
tangemBlockchainSdk = "develop-1544"
|
||||
tangemBlockchainSdk = "develop-1555"
|
||||
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||
tangemCardSdk = "develop-620"
|
||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue