diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index cba4635b01..83e74f5040 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -476,6 +476,16 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + override suspend fun isSwapSpenderAllowed( + userWalletId: UserWalletId, + network: Network, + spenderAddress: String, + ): Boolean = withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + ?: return@withContext false + walletManager.isSwapSpenderAllowed(spenderAddress) + } + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index aa21b96b61..fc7e661cfd 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -246,6 +246,8 @@ interface WalletManagersFacade { */ suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal? + suspend fun isSwapSpenderAllowed(userWalletId: UserWalletId, network: Network, spenderAddress: String): Boolean + /** * Get requirements for asset(currency) * @return null if there's no requirement, otherwise [AssetRequirementsCondition]. 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 9735d76df7..f301453ef5 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 @@ -131,11 +131,29 @@ internal class SwapInteractorImpl @Inject constructor( ConcurrentHashMap(), ) + private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap()) + 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 { + ): Pair? { 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, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index de553ebb28..dd3da80960 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -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`.