Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 16:58:04 +04:00
parent c3740a70c0
commit 312f241f7c
4 changed files with 177 additions and 2 deletions

View file

@ -131,11 +131,29 @@ internal class SwapInteractorImpl @Inject constructor(
ConcurrentHashMap<IntegratedApprovalFallbackKey, Boolean>(),
)
private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap<String, Boolean>())
private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) =
integratedApprovalFallbackContexts.contains(
IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress),
)
private suspend fun isYieldSwapRouterAllowed(
fromSwapCurrencyStatus: SwapCurrencyStatus,
routerAddress: String,
): Boolean {
val network = fromSwapCurrencyStatus.currency.network
val key = "${network.rawId}:${routerAddress.lowercase()}"
if (yieldSwapAllowedRouters.contains(key)) return true
val isAllowed = walletManagersFacade.isSwapSpenderAllowed(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
spenderAddress = routerAddress,
)
if (isAllowed) yieldSwapAllowedRouters.add(key)
return isAllowed
}
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -304,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
}
}.awaitAll().toMap()
}.awaitAll().filterNotNull().toMap()
}
}
@ -316,7 +334,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
): Pair<SwapProvider, SwapState>? {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
@ -366,6 +384,12 @@ internal class SwapInteractorImpl @Inject constructor(
val dexRouterSpenderAddress = maybeQuote.getOrNull()?.allowanceContract
if (isYieldSwap && dexRouterSpenderAddress != null &&
!isYieldSwapRouterAllowed(fromSwapCurrencyStatus, dexRouterSpenderAddress)
) {
return null
}
val allowanceInfo = spenderAddress?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,

View file

@ -888,6 +888,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
coEvery {
yieldModuleAddressProvider.getOrFetch(any(), any())
} returns yieldProxyAddress
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns true
}
@Test
@ -1131,6 +1132,144 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class YieldSwapRouterAllowlist {
private val yieldProxyAddress = "0xYieldModuleProxy"
private val yieldTokenContract = "0xTokenContract"
private val notAllowedRouter = "0xMoonPayRouter"
private val allowedRouter = "0xOneInchRouter"
@BeforeEach
fun enableYieldSwap() {
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns yieldProxyAddress
}
private fun yieldTokenStatus(yieldActive: Boolean = true) = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = yieldActive,
yieldSupplyAllowedToSpend = true,
)
private fun stubDexQuote(providerId: String, router: String) {
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = providerId, rateType = any(),
)
} returns buildQuoteModel(allowanceContract = router).right()
}
private fun stubExchangeData(providerId: String) {
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns buildSwapDataModelDex().right()
}
@Test
fun `should hide yield-swap DEX provider whose router is not allowed by the registry`() = runTest {
// Given — yield active, router NOT in the SwapExecutionRegistry (MoonPay/swaps.xyz)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, notAllowedRouter)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — provider is absent from the list
assertThat(result.containsKey(dexProvider)).isFalse()
assertThat(result).isEmpty()
}
@Test
fun `should keep yield-swap DEX provider whose router is allowed by the registry`() = runTest {
// Given — yield active, router whitelisted (1inch)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, allowedRouter)
stubExchangeData(dexProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then
assertThat(result.containsKey(dexProvider)).isTrue()
assertThat(result[dexProvider]).isNotNull()
}
@Test
fun `should hide only the not-allowed router and keep the allowed one for yield swaps`() = runTest {
// Given — two DEX providers, only one router whitelisted
val allowedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-allowed")
val blockedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-blocked")
stubDexQuote(allowedProvider.providerId, allowedRouter)
stubDexQuote(blockedProvider.providerId, notAllowedRouter)
stubExchangeData(allowedProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(allowedProvider, blockedProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then
assertThat(result.containsKey(allowedProvider)).isTrue()
assertThat(result.containsKey(blockedProvider)).isFalse()
}
@Test
fun `should not apply the registry filter to regular non-yield swaps`() = runTest {
// Given — yield NOT active; the registry verdict must be irrelevant for plain DEX swaps
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, notAllowedRouter)
stubExchangeData(dexProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(yieldActive = false),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — provider stays; the on-chain allowlist does not gate non-yield swaps
assertThat(result.containsKey(dexProvider)).isTrue()
coVerify(exactly = 0) { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) }
}
}
/**
* Regular (non-yield) DEX swap with the integrated-approve toggle ON: the
* `isAllowanceSatisfied` matrix in `manageDex`.