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"))

View file

@ -355,7 +355,8 @@ private fun WalletContent2(
MarketsHint(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = peekHeight + TangemTheme.dimens2.x7),
.fillMaxWidth(fraction = .6f)
.padding(bottom = peekHeight),
isVisible = isShowMarketsHint,
)
}

View file

@ -7,17 +7,16 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -32,29 +31,21 @@ internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) {
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
) {
Column(
verticalArrangement = Arrangement.spacedBy(space = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
text = stringResourceSafe(R.string.markets_hint_part_one),
text = stringResourceSafe(R.string.markets_hint),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.primary,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
Text(
text = stringResourceSafe(R.string.markets_hint_part_two),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_magic_default_24),
tint = TangemTheme.colors2.text.neutral.tertiary,
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens2.x5),
)
}
Icon(
modifier = Modifier.size(size = 24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
}