Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 12:25:46 +04:00
commit e359847e72
5 changed files with 141 additions and 29 deletions

View file

@ -835,14 +835,11 @@ internal class SwapInteractorImpl @Inject constructor(
val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
swapData.transaction.txTo
} else if (txData is TransactionData.Uncompiled) {
getPayoutAddress(txData)
} else {
swapData.transaction.txTo
getPayoutAddress(txData)
}
return if (integratedApproval != null) {
// TODO YIELD payInAddress [REDACTED_TASK_KEY]
sendIntegratedApproveAndSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -852,6 +849,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapTxData = txData,
swapFee = swapFee,
integratedApproval = integratedApproval,
payInAddress = payInAddress,
)
} else {
handleSwapResult(
@ -884,6 +882,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapTxData: TransactionData.Uncompiled,
swapFee: SwapFee,
integratedApproval: IntegratedApprovalData,
payInAddress: String,
): SwapTransactionState {
val approvalFee = selectFeeForBucket(integratedApproval.approvalFee, swapFee.feeBucket)
val approvalTx = integratedApproval.approvalTransaction.copy(fee = approvalFee)
@ -908,7 +907,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
amount = amount,
txHash = swapTxHash,
payInAddress = getPayoutAddress(swapTxData),
payInAddress = payInAddress,
)
}
@ -1344,7 +1343,6 @@ internal class SwapInteractorImpl @Inject constructor(
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,
@ -1993,7 +1991,11 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
provider = provider,
)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive &&
fromSwapCurrencyStatus.currency is CryptoCurrency.Token
val isIntegratedApprovalNeeded = !isYieldSwap &&
swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough &&
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, spenderAddress)
swapState.copy(
@ -2139,7 +2141,8 @@ internal class SwapInteractorImpl @Inject constructor(
requiredAmount = swapAmount.value,
).getOrNull() ?: return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
val isIntegratedApprovalNeeded = !isYieldSwap &&
swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough &&
!hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus, quoteModel.allowanceContract)
return quotesLoadedState.copy(

View file

@ -189,6 +189,38 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
assertThat(state.permissionState).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `GIVEN yield swap AND NotEnough allowance AND integrated active THEN permissionState is not integrated`() =
runTest {
// [REDACTED_TASK_KEY] / iOS parity: yield swaps must never use the integrated approve+swap path.
// The yield-module proxy allowance is granted at enrollment, so no in-flow approval is shown.
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns YIELD_PROXY
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns true
stubAllowance(AllowanceInfo.NotEnough(allowance = BigDecimal.ZERO, requiredAmount = BigDecimal.ONE))
val dexProvider = stubDexQuoteAndExchangeData()
val result = sut.findBestQuote(
fromSwapCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = false,
contractAddress = "0xToken",
amount = BigDecimal("10"),
yieldSupplyActive = true,
),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
val state = result[dexProvider] as SwapState.QuotesLoadedState
assertThat(state.permissionState)
.isNotInstanceOf(PermissionDataState.PermissionSettings::class.java)
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),
@ -290,5 +322,6 @@ internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTe
private companion object {
const val SPENDER = "0xSpender"
const val YIELD_PROXY = "0xYieldProxy"
}
}

View file

@ -168,6 +168,53 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java)
}
@Test
fun `GIVEN yield integratedApproval WHEN onSwap THEN exchangeSent uses dex router txTo as payInAddress not yield proxy`() =
runTest {
// Arrange — yield-active token swap is routed through the yield module proxy: the swap tx is
// addressed to the proxy, but the Express status must be tracked by the original dex router (txTo).
// [REDACTED_TASK_KEY] / [REDACTED_TASK_KEY]: otherwise the "Supplying to Aave" status never resolves.
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns YIELD_PROXY
coEvery {
createTransactionExtrasUseCase(callData = any(), network = any(), gasLimit = any())
} returns mockk<TransactionExtras>(relaxed = true).right()
coEvery {
createTransactionUseCase(
amount = any(), fee = any(), memo = any(),
destination = any(), userWalletId = any(), network = any(), txExtras = any(),
)
} returns yieldSwapTxUncompiled().right()
coEvery {
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
} returns listOf(APPROVAL_HASH, SWAP_HASH).right()
val payInSlot = slot<String>()
coEvery {
repository.exchangeSent(
userWallet = any(), txId = any(), fromNetwork = any(), fromAddress = any(),
payInAddress = capture(payInSlot), txHash = any(), payInExtraId = any(),
)
} returns Unit.right()
// Act
val result = sut.onSwap(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = hotStatus(),
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
swapData = yieldDexSwapData(),
amountToSwap = "1.0",
balanceStatus = SwapBalanceStatus.Sufficient,
fee = buildSwapFee(),
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = false,
integratedApproval = integratedApproval(approvalFee = singleFee()),
)
// Assert — backend receives the original dex router address, not the yield module proxy.
assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java)
assertThat(payInSlot.captured).isEqualTo(DEX_ROUTER)
}
@Test
fun `GIVEN Choosable approval fee AND SLOW bucket THEN approval tx fee is the minimum`() = runTest {
assertApprovalFeeBucket(
@ -270,6 +317,41 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
destinationAddress = "0xTo",
)
/** Yield-swap tx is addressed to the yield module proxy, not to the dex router. */
private fun yieldSwapTxUncompiled(): TransactionData.Uncompiled = TransactionData.Uncompiled(
amount = realAmount(),
fee = NORMAL_FEE,
sourceAddress = "0xFrom",
destinationAddress = YIELD_PROXY,
)
private fun yieldTokenStatus(): SwapCurrencyStatus {
val hotWallet = mockk<UserWallet.Hot>(relaxed = true)
return buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xTokenContract",
isCoin = false,
yieldSupplyActive = true,
).let { SwapCurrencyStatus(userWallet = hotWallet, status = it.status, account = it.account) }
}
private fun yieldDexSwapData(): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "0",
txId = "tx-id",
txTo = DEX_ROUTER,
txExtraId = null,
txFrom = "0xFrom",
txData = "0xdata",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
allowanceContract = "0xSpender",
),
)
private fun realAmount(): Amount = Amount(
currencySymbol = "ETH",
value = BigDecimal.ONE,
@ -369,6 +451,8 @@ internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() {
private companion object {
const val APPROVAL_HASH = "0xApprovalHash"
const val SWAP_HASH = "0xSwapHash"
const val DEX_ROUTER = "0xDexRouter"
const val YIELD_PROXY = "0xYieldProxy"
val MIN_FEE: Fee = feeOf(BigDecimal("0.001"))
val NORMAL_FEE: Fee = feeOf(BigDecimal("0.002"))