Updated on 2026-08-14
This commit is contained in:
commit
e359847e72
5 changed files with 141 additions and 29 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue