diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index f301453ef5..9e3e2227b4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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( diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt index 66736a8e47..5004c017ad 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -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" } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt index 6855f1cf9f..6e5aa5e58c 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt @@ -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(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() + 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(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")) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 2329a20839..ebd2c17955 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -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, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt index c34f8e901b..9ac6ed900e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/MarketsHint.kt @@ -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, + ) } } }