From 5deb752c04dd4edaf5a5e545e1b2de44b39f25d6 Mon Sep 17 00:00:00 2001 From: Oleg Leonov Date: Sat, 1 Aug 2026 16:32:30 +0300 Subject: [PATCH 1/3] Add auto calculation for fiat/coin in out field --- .../cash/p/terminal/core/di/UseCaseModule.kt | 2 + .../core/usecase/FetchSwapQuotesUseCase.kt | 148 ++- .../core/usecase/IterativeExactOutSearch.kt | 340 +++++ .../terminal/modules/multiswap/FiatService.kt | 55 +- .../modules/multiswap/ISwapFinalQuote.kt | 4 +- .../terminal/modules/multiswap/ISwapQuote.kt | 11 +- .../multiswap/MultiSwapRouteResolver.kt | 2 +- .../modules/multiswap/SwapAmountDirection.kt | 17 + .../modules/multiswap/SwapConfirmScreen.kt | 606 +++++---- .../modules/multiswap/SwapConfirmViewModel.kt | 243 ++-- .../modules/multiswap/SwapFragment.kt | 1135 +++++++++-------- .../modules/multiswap/SwapProviderQuote.kt | 17 +- .../modules/multiswap/SwapQuoteService.kt | 127 +- .../modules/multiswap/SwapRouteNotFound.kt | 1 + .../multiswap/SwapSelectProviderScreen.kt | 15 +- .../multiswap/SwapSelectProviderViewModel.kt | 80 +- .../modules/multiswap/SwapViewModel.kt | 156 ++- .../exchange/MultiSwapExchangeViewModel.kt | 4 +- .../exchanges/MultiSwapExchangesFragment.kt | 32 +- .../multiswap/providers/AllBridgeProvider.kt | 19 +- .../providers/BaseUniswapProvider.kt | 249 ++-- .../providers/BaseUniswapV3Provider.kt | 272 ++-- .../providers/IExactOutSwapProvider.kt | 35 + .../multiswap/providers/StonFiProvider.kt | 643 ++++++---- .../modules/multiswap/providers/SwapHelper.kt | 20 + .../multiswap/providers/UniswapV3Provider.kt | 2 +- .../modules/paycore/PayCoreApiDtos.kt | 16 + .../modules/paycore/PayCoreProvider.kt | 113 +- .../payment/PayCorePaymentViewModel.kt | 24 +- .../usecase/FetchSwapQuotesUseCaseTest.kt | 232 +++- .../usecase/IterativeExactOutSearchTest.kt | 386 ++++++ .../modules/multiswap/FiatServiceTest.kt | 68 + .../multiswap/SwapConfirmViewModelLeg2Test.kt | 18 +- .../multiswap/SwapConfirmViewModelSaveTest.kt | 150 ++- .../multiswap/SwapPayCoreNavigationTest.kt | 109 ++ .../modules/multiswap/SwapQuoteServiceTest.kt | 414 +++++- .../SwapSelectProviderViewModelTest.kt | 31 +- .../multiswap/SwapViewModelFiatInputTest.kt | 222 ++++ .../providers/AllBridgeProviderTest.kt | 24 + .../multiswap/providers/StonFiProviderTest.kt | 34 + .../multiswap/providers/SwapHelperTest.kt | 38 + .../modules/paycore/PayCoreProviderTest.kt | 121 +- .../payment/PayCorePaymentViewModelTest.kt | 85 +- .../SwapOutputPreviewScreenshotTest.kt | 54 + .../terminal/network/stonfi/api/StonFiApi.kt | 30 +- .../data/repository/StonFiRepositoryImpl.kt | 45 + .../domain/repository/StonFiRepository.kt | 11 + .../src/main/res/values-ar/strings.xml | 3 + .../src/main/res/values-de/strings.xml | 3 + .../src/main/res/values-es/strings.xml | 3 + .../src/main/res/values-fa/strings.xml | 3 + .../src/main/res/values-fr/strings.xml | 3 + .../src/main/res/values-ko/strings.xml | 3 + .../src/main/res/values-nl/strings.xml | 3 + .../src/main/res/values-pt-rBR/strings.xml | 3 + .../src/main/res/values-pt/strings.xml | 3 + .../src/main/res/values-ru/strings.xml | 3 + .../src/main/res/values-tr/strings.xml | 3 + .../src/main/res/values-uk/strings.xml | 3 + .../src/main/res/values-zh/strings.xml | 3 + core/strings/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 2 +- 62 files changed, 4998 insertions(+), 1506 deletions(-) create mode 100644 app/src/main/java/cash/p/terminal/core/usecase/IterativeExactOutSearch.kt create mode 100644 app/src/main/java/cash/p/terminal/modules/multiswap/SwapAmountDirection.kt create mode 100644 app/src/main/java/cash/p/terminal/modules/multiswap/providers/IExactOutSwapProvider.kt create mode 100644 app/src/test/java/cash/p/terminal/core/usecase/IterativeExactOutSearchTest.kt create mode 100644 app/src/test/java/cash/p/terminal/modules/multiswap/SwapPayCoreNavigationTest.kt create mode 100644 app/src/test/java/cash/p/terminal/modules/multiswap/SwapViewModelFiatInputTest.kt create mode 100644 app/src/test/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProviderTest.kt create mode 100644 app/src/test/java/cash/p/terminal/modules/multiswap/providers/StonFiProviderTest.kt create mode 100644 app/src/test/java/cash/p/terminal/modules/multiswap/providers/SwapHelperTest.kt create mode 100644 app/src/test/java/cash/p/terminal/screenshots/SwapOutputPreviewScreenshotTest.kt diff --git a/app/src/main/java/cash/p/terminal/core/di/UseCaseModule.kt b/app/src/main/java/cash/p/terminal/core/di/UseCaseModule.kt index da2e443a7ad..4a44cbf734d 100644 --- a/app/src/main/java/cash/p/terminal/core/di/UseCaseModule.kt +++ b/app/src/main/java/cash/p/terminal/core/di/UseCaseModule.kt @@ -10,6 +10,7 @@ import cash.p.terminal.core.usecase.GetMoneroWalletFilesNameUseCase import cash.p.terminal.core.usecase.GetRestoreHeightForWalletUseCase import cash.p.terminal.core.usecase.MoneroWalletUseCase import cash.p.terminal.core.usecase.FetchSwapQuotesUseCase +import cash.p.terminal.core.usecase.IterativeExactOutSearch import cash.p.terminal.core.usecase.RescanMoneroUseCase import cash.p.terminal.core.usecase.RescanZcashUseCase import cash.p.terminal.core.usecase.ResolvePayCoreNavigationUseCase @@ -40,6 +41,7 @@ val useCaseModule = module { singleOf(::UpdateSwapProviderTransactionsStatusUseCase) singleOf(::SyncPendingMultiSwapUseCase) factoryOf(::FetchSwapQuotesUseCase) + factoryOf(::IterativeExactOutSearch) factoryOf(::ResolveTransactionItemUseCase) factoryOf(::ResolvePayCoreNavigationUseCase) factoryOf(::ValidateMoneroMnemonicUseCase) diff --git a/app/src/main/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCase.kt b/app/src/main/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCase.kt index 7d01380e849..81ba3b55155 100644 --- a/app/src/main/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCase.kt +++ b/app/src/main/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCase.kt @@ -1,8 +1,11 @@ package cash.p.terminal.core.usecase +import cash.p.terminal.modules.multiswap.SwapAmountDirection +import cash.p.terminal.modules.multiswap.SwapExecutionMode import cash.p.terminal.modules.multiswap.SwapProviderQuote -import cash.p.terminal.modules.multiswap.sortedByBestAmountOut +import cash.p.terminal.modules.multiswap.providers.IExactOutSwapProvider import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.modules.multiswap.sortedByBest import cash.p.terminal.wallet.Token import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -12,71 +15,152 @@ import timber.log.Timber import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException -class FetchSwapQuotesUseCase { - +class FetchSwapQuotesUseCase( + private val iterativeExactOutSearch: IterativeExactOutSearch, +) { suspend operator fun invoke( providers: List, tokenIn: Token, tokenOut: Token, - amountIn: BigDecimal, + amount: BigDecimal, + direction: SwapAmountDirection, settings: Map = emptyMap(), onProviderError: ((IMultiSwapProvider, Throwable) -> Unit)? = null, ): List = coroutineScope { - val supported = findSupportedProviders(providers, tokenIn, tokenOut) + val supported = findSupportedProviders(providers, tokenIn, tokenOut, direction) if (supported.isEmpty()) return@coroutineScope emptyList() - fetchQuotes(supported, tokenIn, tokenOut, amountIn, settings, onProviderError) - .sortedByBestAmountOut() + supported.map { supportedProvider -> + async { + fetchQuote( + supportedProvider, + tokenIn, + tokenOut, + amount, + direction, + settings, + onProviderError, + ) + } + }.awaitAll().filterNotNull().sortedByBest(direction) } suspend fun findSupportedProviders( providers: List, tokenIn: Token, tokenOut: Token, - ): List = coroutineScope { + direction: SwapAmountDirection = SwapAmountDirection.In, + ): List = coroutineScope { providers.map { provider -> async { try { - withTimeoutOrNull(TIMEOUT_MS) { - if (provider.supports(tokenIn, tokenOut)) provider else null + withTimeoutOrNull(SUPPORTS_TIMEOUT_MS) { + resolveSupport(provider, tokenIn, tokenOut, direction) } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - Timber.d(e, "supports error: ${provider.id}") + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Timber.d(error, "supports error: ${provider.id}") null } } }.awaitAll().filterNotNull() } - private suspend fun fetchQuotes( - providers: List, + suspend fun invalidateSearchCache() { + iterativeExactOutSearch.invalidate() + } + + private suspend fun resolveSupport( + provider: IMultiSwapProvider, tokenIn: Token, tokenOut: Token, - amountIn: BigDecimal, + direction: SwapAmountDirection, + ): SupportedProvider? = when (direction) { + SwapAmountDirection.In -> provider.takeIf { it.supports(tokenIn, tokenOut) } + ?.let { SupportedProvider(it, nativeExactOut = false) } + + SwapAmountDirection.Out -> { + val exactOutProvider = provider as? IExactOutSwapProvider + when { + exactOutProvider?.supportsExactOut(tokenIn, tokenOut) == true -> + SupportedProvider(provider, nativeExactOut = true) + + exactOutProvider != null -> null + + provider.supports(tokenIn, tokenOut) -> + SupportedProvider(provider, nativeExactOut = false) + + else -> null + } + } + } + + private suspend fun fetchQuote( + supportedProvider: SupportedProvider, + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + direction: SwapAmountDirection, settings: Map, onProviderError: ((IMultiSwapProvider, Throwable) -> Unit)?, - ) = coroutineScope { - providers.map { provider -> - async { - try { - withTimeoutOrNull(TIMEOUT_MS) { - val quote = provider.fetchQuote(tokenIn, tokenOut, amountIn, settings) - SwapProviderQuote(provider = provider, swapQuote = quote) + ): SwapProviderQuote? { + val provider = supportedProvider.provider + return try { + withTimeoutOrNull(timeout(direction)) { + when { + direction == SwapAmountDirection.In -> SwapProviderQuote( + provider = provider, + swapQuote = provider.fetchQuote(tokenIn, tokenOut, amount, settings), + ) + + supportedProvider.nativeExactOut -> { + val exactOutProvider = provider as IExactOutSwapProvider + SwapProviderQuote( + provider = provider, + swapQuote = exactOutProvider.fetchQuoteExactOut( + tokenIn, + tokenOut, + amount, + settings, + ), + executionMode = SwapExecutionMode.NativeExactOut, + amountOutAccuracy = exactOutProvider.exactOutAccuracy, + ) } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - onProviderError?.invoke(provider, e) - ?: Timber.d(e, "fetchQuoteError: ${provider.id}") - null + + else -> iterativeExactOutSearch.search( + provider, + tokenIn, + tokenOut, + amount, + settings, + onProviderError, + ) } } - }.awaitAll().filterNotNull() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onProviderError?.invoke(provider, error) + ?: Timber.d(error, "fetchQuoteError: ${provider.id}") + null + } } + private fun timeout(direction: SwapAmountDirection): Long = when (direction) { + SwapAmountDirection.In -> EXACT_IN_TIMEOUT_MS + SwapAmountDirection.Out -> EXACT_OUT_TIMEOUT_MS + } + + data class SupportedProvider( + val provider: IMultiSwapProvider, + val nativeExactOut: Boolean, + ) + private companion object { - const val TIMEOUT_MS = 5000L + const val SUPPORTS_TIMEOUT_MS = 5_000L + const val EXACT_IN_TIMEOUT_MS = 5_000L + const val EXACT_OUT_TIMEOUT_MS = 12_000L } } diff --git a/app/src/main/java/cash/p/terminal/core/usecase/IterativeExactOutSearch.kt b/app/src/main/java/cash/p/terminal/core/usecase/IterativeExactOutSearch.kt new file mode 100644 index 00000000000..5899d0206bd --- /dev/null +++ b/app/src/main/java/cash/p/terminal/core/usecase/IterativeExactOutSearch.kt @@ -0,0 +1,340 @@ +package cash.p.terminal.core.usecase + +import cash.p.terminal.modules.multiswap.AssetFiatRateService +import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.SwapExecutionMode +import cash.p.terminal.modules.multiswap.SwapProviderQuote +import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.wallet.Token +import io.horizontalsystems.core.CurrencyManager +import io.horizontalsystems.core.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.ln +import kotlin.math.pow + +class IterativeExactOutSearch( + private val assetFiatRateService: AssetFiatRateService, + private val currencyManager: CurrencyManager, + dispatcherProvider: DispatcherProvider, +) { + internal var currentTimeMillis: () -> Long = System::currentTimeMillis + private val scope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) + private val mutex = Mutex() + private val results = mutableMapOf() + private val inFlight = mutableMapOf() + private var generation = 0 + + suspend fun search( + provider: IMultiSwapProvider, + tokenIn: Token, + tokenOut: Token, + target: BigDecimal, + settings: Map, + onProviderError: ((IMultiSwapProvider, Throwable) -> Unit)? = null, + ): SwapProviderQuote? { + val key = Key(provider.id, tokenIn, tokenOut, target, settings.hashCode()) + val entry = mutex.withLock { + cachedResult(key)?.let { return it } + inFlight[key]?.also { it.waiters++ } ?: createInFlight( + key = key, + provider = provider, + tokenIn = tokenIn, + tokenOut = tokenOut, + target = target, + settings = settings, + onProviderError = onProviderError, + ).also { + it.waiters = 1 + inFlight[key] = it + } + } + + entry.deferred.start() + return try { + entry.deferred.await() + } finally { + release(key, entry) + } + } + + suspend fun invalidate() { + val detached = mutex.withLock { + generation++ + results.clear() + inFlight.values.toList().also { inFlight.clear() } + } + detached.forEach { it.deferred.cancel() } + } + + private fun createInFlight( + key: Key, + provider: IMultiSwapProvider, + tokenIn: Token, + tokenOut: Token, + target: BigDecimal, + settings: Map, + onProviderError: ((IMultiSwapProvider, Throwable) -> Unit)?, + ): InFlight { + val entryGeneration = generation + val entry = InFlight(entryGeneration) + entry.deferred = scope.async(start = CoroutineStart.LAZY) { + val result = findQuote( + provider, + tokenIn, + tokenOut, + target, + settings, + onProviderError, + ) + if (result != null) { + cache(key, entry.generation, result) + } + result + } + return entry + } + + private suspend fun release(key: Key, entry: InFlight) = withContext(NonCancellable) { + val cancel = mutex.withLock { + entry.waiters-- + if (entry.waiters == 0 && inFlight[key] === entry) { + inFlight.remove(key) + true + } else { + false + } + } + if (cancel) entry.deferred.cancel() + } + + private fun cachedResult(key: Key): SwapProviderQuote? { + val result = results[key] ?: return null + return result.takeIf { currentTimeMillis() - it.createdAt <= CACHE_TTL_MS } + ?: run { + results.remove(key) + null + } + } + + private suspend fun cache(key: Key, entryGeneration: Int, quote: SwapProviderQuote) { + mutex.withLock { + if (entryGeneration != generation) return@withLock + val now = currentTimeMillis() + results.entries.removeAll { now - it.value.createdAt > CACHE_TTL_MS } + results[key] = quote + } + } + + private suspend fun findQuote( + provider: IMultiSwapProvider, + tokenIn: Token, + tokenOut: Token, + target: BigDecimal, + settings: Map, + onProviderError: ((IMultiSwapProvider, Throwable) -> Unit)?, + ): SwapProviderQuote? { + return try { + performSearch(provider, tokenIn, tokenOut, target, settings) + .also { + if (it == null) Timber.d("Exact-out search did not converge: ${provider.id}") + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onProviderError?.invoke(provider, error) + ?: Timber.d(error, "Exact-out search failed: ${provider.id}") + null + } + } + + private suspend fun performSearch( + provider: IMultiSwapProvider, + tokenIn: Token, + tokenOut: Token, + target: BigDecimal, + settings: Map, + ): SwapProviderQuote? { + val upperTarget = target.multiply(ACCEPTANCE_MULTIPLIER) + val progress = SearchProgress(initialCandidate(tokenIn, tokenOut, target)) + + repeat(MAX_REQUESTS) { + val candidate = progress.nextUnattempted(tokenIn.decimals) ?: return null + val quote = provider.fetchQuote(tokenIn, tokenOut, candidate, settings) + val current = Probe(candidate, quote.amountOut) + if (current.output >= target && current.output <= upperTarget) { + return estimatedQuote(provider, quote) + } + + progress.record(current, target) + progress.candidate = nextCandidate( + current = current, + previous = progress.previous, + lower = progress.lower, + upper = progress.upper, + target = target, + decimals = tokenIn.decimals, + ) ?: return null + progress.previous = current + } + return null + } + + private fun estimatedQuote( + provider: IMultiSwapProvider, + quote: ISwapQuote, + ) = SwapProviderQuote( + provider = provider, + swapQuote = quote, + executionMode = SwapExecutionMode.ExactIn, + amountOutAccuracy = SwapAmountAccuracy.Estimated, + createdAt = currentTimeMillis(), + ) + + private suspend fun initialCandidate( + tokenIn: Token, + tokenOut: Token, + target: BigDecimal, + ): BigDecimal { + val currency = currencyManager.baseCurrency + val priceIn = assetFiatRateService.rate(tokenIn, currency) + val priceOut = assetFiatRateService.rate(tokenOut, currency) + if (cannotCalculateExponent(priceIn, priceOut)) { + return target + } + requireNotNull(priceIn) + requireNotNull(priceOut) + val rate = priceIn.divide(priceOut, MathContext.DECIMAL64) + return target.divide(rate, MathContext.DECIMAL64).multiply(START_MULTIPLIER) + } + + private fun cannotCalculateExponent(priceIn: BigDecimal?, priceOut: BigDecimal?): Boolean = + priceIn == null || priceOut == null || + priceIn.signum() <= 0 || priceOut.signum() <= 0 + + private fun nextCandidate( + current: Probe, + previous: Probe?, + lower: Probe?, + upper: Probe?, + target: BigDecimal, + decimals: Int, + ): BigDecimal? { + val proposed = logarithmicStep(current, previous, target) + val withinBracket = proposed?.takeIf { candidate -> + (lower == null || candidate > lower.input) && (upper == null || candidate < upper.input) + } + return normalizeCandidate(withinBracket ?: bisect(lower, upper) ?: return null, decimals) + } + + private fun logarithmicStep( + current: Probe, + previous: Probe?, + target: BigDecimal, + ): BigDecimal? { + if (current.output <= BigDecimal.ZERO) return null + + val exponent = if (previous == null) { + 1.0 + } else { + if (cannotCalculateExponent(current, previous)) return null + val denominator = ln(current.input.toDouble() / previous.input.toDouble()) + val value = ln(current.output.toDouble() / previous.output.toDouble()) / denominator + if (!value.isFinite()) return null + value.coerceIn(MIN_EXPONENT, MAX_EXPONENT) + } + + val value = current.input.toDouble() * + (target.toDouble() / current.output.toDouble()).pow(1.0 / exponent) * + MID_BAND_MULTIPLIER + return value.takeIf(Double::isFinite)?.let(BigDecimal::valueOf) + } + + private fun cannotCalculateExponent(current: Probe, previous: Probe): Boolean = + current.input.compareTo(previous.input) == 0 || + current.output.compareTo(previous.output) == 0 || + previous.output <= BigDecimal.ZERO + + private fun bisect(lower: Probe?, upper: Probe?): BigDecimal? { + if (lower == null || upper == null) return null + return lower.input.add(upper.input).divide(TWO, MathContext.DECIMAL64) + } + + private fun normalizeCandidate(value: BigDecimal, decimals: Int): BigDecimal? { + if (value <= BigDecimal.ZERO) return null + return try { + value.setScale(decimals, RoundingMode.UP).stripTrailingZeros() + } catch (_: ArithmeticException) { + null + } + } + + private data class Probe( + val input: BigDecimal, + val output: BigDecimal, + ) + + private inner class SearchProgress( + var candidate: BigDecimal, + var previous: Probe? = null, + var lower: Probe? = null, + var upper: Probe? = null, + private val attempted: MutableSet = mutableSetOf(), + ) { + fun nextUnattempted(decimals: Int): BigDecimal? { + val normalized = normalizeCandidate(candidate, decimals) ?: return null + return normalized.takeIf(attempted::add) + } + + fun record(probe: Probe, target: BigDecimal) { + if (probe.output < target) { + if (lower == null || probe.output > requireNotNull(lower).output) { + lower = probe + } + } else { + if (upper == null || probe.output < requireNotNull(upper).output) { + upper = probe + } + } + } + } + + private data class Key( + val providerId: String, + val tokenIn: Token, + val tokenOut: Token, + val target: BigDecimal, + val settingsHash: Int, + ) + + private class InFlight( + val generation: Int, + var waiters: Int = 0, + ) { + lateinit var deferred: Deferred + } + + private companion object { + const val MAX_REQUESTS = 4 + const val CACHE_TTL_MS = 20_000L + const val MIN_EXPONENT = 0.2 + const val MAX_EXPONENT = 2.0 + const val MID_BAND_MULTIPLIER = 1.0025 + val TWO = BigDecimal(2) + val ACCEPTANCE_MULTIPLIER = BigDecimal("1.005") + val START_MULTIPLIER = BigDecimal("1.02") + } +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/FiatService.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/FiatService.kt index 357b6e779f6..f661dea1f75 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/FiatService.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/FiatService.kt @@ -1,7 +1,6 @@ package cash.p.terminal.modules.multiswap import cash.p.terminal.core.ServiceState -import cash.p.terminal.wallet.Clearable import cash.p.terminal.wallet.Token import io.horizontalsystems.core.entities.Currency import kotlinx.coroutines.CoroutineDispatcher @@ -24,6 +23,7 @@ class FiatService( private var rate: BigDecimal? = null private var fiatAmount: BigDecimal? = null + private var inputSource = InputSource.Token private var rateUpdatesJob: Job? = null private val job = SupervisorJob() @@ -32,7 +32,8 @@ class FiatService( override fun createState() = State( rate = rate, amount = amount, - fiatAmount = fiatAmount + fiatAmount = fiatAmount, + inputSource = inputSource, ) private fun refreshRate() { @@ -60,6 +61,13 @@ class FiatService( } } + private fun refreshConvertedAmount() { + when (inputSource) { + InputSource.Token -> refreshFiatAmount() + InputSource.Fiat -> refreshAmount() + } + } + private fun resubscribeForRate() { rateUpdatesJob?.cancel() val currency = currency ?: return @@ -67,9 +75,11 @@ class FiatService( token?.let { token -> rateUpdatesJob = coroutineScope.launch { assetFiatRateService.rateFlow("swap", token, currency) - .collect { - rate = it - refreshFiatAmount() + .collect { updatedRate -> + if (rate == updatedRate) return@collect + + rate = updatedRate + refreshConvertedAmount() emitState() } } @@ -82,7 +92,7 @@ class FiatService( this.currency = currency refreshRate() - refreshFiatAmount() + refreshConvertedAmount() emitState() } @@ -93,12 +103,13 @@ class FiatService( this.token = token refreshRate() - refreshFiatAmount() + refreshConvertedAmount() emitState() } fun setAmount(amount: BigDecimal?) { + if (inputSource == InputSource.Fiat) return if (this.amount == amount) return this.amount = amount @@ -107,15 +118,35 @@ class FiatService( emitState() } + fun setInputAmount(amount: BigDecimal?) { + if (inputSource == InputSource.Token && this.amount == amount) return + + inputSource = InputSource.Token + this.amount = amount + refreshFiatAmount() + + emitState() + } + fun setFiatAmount(fiatAmount: BigDecimal?) { - if (this.fiatAmount == fiatAmount) return + if (inputSource == InputSource.Fiat && this.fiatAmount == fiatAmount) return + inputSource = InputSource.Fiat this.fiatAmount = fiatAmount refreshAmount() emitState() } + fun useTokenAmount() { + if (inputSource == InputSource.Token) return + + inputSource = InputSource.Token + refreshFiatAmount() + + emitState() + } + override fun close() { coroutineScope.cancel() } @@ -123,6 +154,12 @@ class FiatService( data class State( val amount: BigDecimal?, val fiatAmount: BigDecimal?, - val rate: BigDecimal? + val rate: BigDecimal?, + val inputSource: InputSource, ) + + enum class InputSource { + Token, + Fiat, + } } diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapFinalQuote.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapFinalQuote.kt index 39667212dd0..fa521dac459 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapFinalQuote.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapFinalQuote.kt @@ -11,6 +11,7 @@ interface ISwapFinalQuote { val tokenIn: Token val tokenOut: Token val amountIn: BigDecimal + val amountInMax: BigDecimal? get() = null val amountOut: BigDecimal val amountOutMin: BigDecimal? val sendTransactionData: SendTransactionData @@ -31,6 +32,7 @@ data class SwapFinalQuoteEvm( override val sendTransactionData: SendTransactionData, override val priceImpact: BigDecimal?, override val fields: List, + override val amountInMax: BigDecimal? = null, override val cautions: List = listOf(), override val swapProviderTransaction: SwapProviderTransaction? = null, ) : ISwapFinalQuote @@ -46,4 +48,4 @@ data class SwapFinalQuoteThorChain( override val fields: List, override val cautions: MutableList, override val swapProviderTransaction: SwapProviderTransaction? = null, -) : ISwapFinalQuote \ No newline at end of file +) : ISwapFinalQuote diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapQuote.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapQuote.kt index 25272bffd7b..e9733bc234a 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapQuote.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/ISwapQuote.kt @@ -6,11 +6,13 @@ import cash.p.terminal.modules.multiswap.settings.ISwapSetting import cash.p.terminal.modules.multiswap.ui.DataField import cash.p.terminal.wallet.Token import io.horizontalsystems.uniswapkit.models.TradeData +import io.horizontalsystems.uniswapkit.models.TradeType import io.horizontalsystems.uniswapkit.v3.TradeDataV3 import java.math.BigDecimal interface ISwapQuote { val amountOut: BigDecimal + val amountInMax: BigDecimal? get() = null val priceImpact: BigDecimal? val fields: List val settings: List @@ -36,7 +38,9 @@ class SwapQuoteUniswap( override val actionRequired: ISwapProviderAction?, override val cautions: List = listOf() ) : ISwapQuote { - override val amountOut: BigDecimal = tradeData.amountOut!! + override val amountOut: BigDecimal = requireNotNull(tradeData.amountOut) + override val amountInMax: BigDecimal? + get() = tradeData.amountInMax.takeIf { tradeData.type == TradeType.ExactOut } override val priceImpact: BigDecimal? = tradeData.priceImpact override val estimationTime: Long? = null } @@ -51,7 +55,10 @@ class SwapQuoteUniswapV3( override val actionRequired: ISwapProviderAction?, override val cautions: List = listOf() ) : ISwapQuote { - override val amountOut = tradeDataV3.tokenAmountOut.decimalAmount!! + override val amountOut = requireNotNull(tradeDataV3.tokenAmountOut.decimalAmount) + override val amountInMax: BigDecimal? + get() = tradeDataV3.tokenAmountInMaximum.decimalAmount + ?.takeIf { tradeDataV3.tradeType == TradeType.ExactOut } override val priceImpact = tradeDataV3.priceImpact override val estimationTime: Long? = null } diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/MultiSwapRouteResolver.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/MultiSwapRouteResolver.kt index 6701438eef9..9ba40cd3e83 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/MultiSwapRouteResolver.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/MultiSwapRouteResolver.kt @@ -170,7 +170,7 @@ class MultiSwapRouteResolver( } }.awaitAll() .filterNotNull() - .sortedByBestAmountOut() + .sortedByBest(SwapAmountDirection.In) } private fun commissionReserve(intermediate: Token): BigDecimal = diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapAmountDirection.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapAmountDirection.kt new file mode 100644 index 00000000000..178207a8187 --- /dev/null +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapAmountDirection.kt @@ -0,0 +1,17 @@ +package cash.p.terminal.modules.multiswap + +enum class SwapAmountDirection { + In, + Out, +} + +enum class SwapExecutionMode { + ExactIn, + NativeExactOut, +} + +enum class SwapAmountAccuracy { + Exact, + AtLeast, + Estimated, +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmScreen.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmScreen.kt index 62fce1147ed..abf42a12e3d 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmScreen.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmScreen.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,6 +33,7 @@ import cash.p.terminal.modules.multiswap.exchanges.MultiSwapExchangesFragment import cash.p.terminal.modules.send.SendResult import cash.p.terminal.modules.send.hasInsufficientFeeTokenBalance import cash.p.terminal.modules.send.fee.NetworkFeeWarningOverlay +import cash.p.terminal.modules.send.fee.NetworkFeeWarningData import cash.p.terminal.navigation.navigateUpSafely import cash.p.terminal.navigation.slideFromRight import cash.p.terminal.ui.compose.components.CoinImage @@ -64,66 +65,132 @@ import io.horizontalsystems.core.entities.CurrencyValue import kotlinx.coroutines.delay import java.math.BigDecimal +data class SwapConfirmNavigation( + val fragment: NavController, + val swap: NavController, +) + +data class SwapConfirmQuoteParams( + val quote: SwapProviderQuote, + val settings: Map, + val direction: SwapAmountDirection, + val requestedAmountOut: BigDecimal?, + val multiSwapLegInfo: MultiSwapLegInfo?, +) + +data class SwapConfirmBalanceParams( + val provider: IMultiSwapProvider?, + val displayBalance: BigDecimal?, + val balanceHidden: Boolean, + val feeToken: Token?, + val feeCoinBalance: BigDecimal?, +) + @Composable fun SwapConfirmScreen( - fragmentNavController: NavController, - swapNavController: NavController, - quote: SwapProviderQuote, - settings: Map, - provider: IMultiSwapProvider?, - displayBalance: BigDecimal?, - balanceHidden: Boolean, - feeToken: Token?, - feeCoinBalance: BigDecimal?, + navigation: SwapConfirmNavigation, + quoteParams: SwapConfirmQuoteParams, + balanceParams: SwapConfirmBalanceParams, onToggleHideBalance: () -> Unit, + onReapprove: () -> Unit, onOpenSettings: (() -> Unit)? = null, - multiSwapLegInfo: MultiSwapLegInfo? = null, ) { - val view = LocalView.current - - val currentBackStackEntry = remember { swapNavController.currentBackStackEntry } - val viewModel = viewModel( - viewModelStoreOwner = requireNotNull(currentBackStackEntry), - factory = SwapConfirmViewModel.provideFactory( - quote, settings, fragmentNavController, multiSwapLegInfo - ) - ) - + val viewModel = swapConfirmViewModel(navigation, quoteParams) val uiState = viewModel.uiState - val sendResult = viewModel.sendResult - var currentSnackbar by remember { mutableStateOf(null) } val hasInsufficientFeeBalance = hasInsufficientFeeTokenBalance( token = uiState.tokenIn, fee = uiState.networkFee?.primary?.value, - feeTokenBalance = feeCoinBalance, + feeTokenBalance = balanceParams.feeCoinBalance, ) || viewModel.isInsufficientFeeBalance(uiState.networkFee?.primary?.value) val hasFeeProblem = hasSwapConfirmFeeProblem( hasInsufficientFeeBalance = hasInsufficientFeeBalance, hasFeeCaution = uiState.feeCaution != null, ) + val actions = SwapConfirmActions( + refresh = viewModel::refresh, + reapprove = onReapprove, + retryAdapter = viewModel::retryAdapterSync, + send = viewModel::onClickSendWithWarningCheck, + toggleMevProtection = viewModel::toggleMevProtection, + ) + val runtime = SwapConfirmRuntime( + isSynced = viewModel.isSynced, + hasAdapterError = viewModel.hasAdapterError, + sendResult = viewModel.sendResult, + inlineFeeWarningData = viewModel.inlineFeeWarningData, + ) - // Handle send result UI - must be in Composable context for getString() - currentSnackbar = when (sendResult) { - SendResult.Sending -> { - HudHelper.showInProcessMessage( - view, - R.string.Swap_Swapping, - SnackbarDuration.INDEFINITE - ) - } + SwapResultEffects(viewModel, navigation, quoteParams.multiSwapLegInfo) - is SendResult.Sent -> { - HudHelper.showSuccessMessage(view, R.string.Hud_Text_Done) + ConfirmTransactionScreen( + onClickBack = navigation.swap::navigateUpSafely, + onClickSettings = if (uiState.isAdvancedSettingsAvailable && onOpenSettings != null) { + { onOpenSettings.invoke() } + } else { + null + }, + onClickClose = null, + buttonsSlot = { + SwapConfirmButtons(uiState, runtime, actions, hasFeeProblem) } + ) { + SwapConfirmContent( + uiState, + navigation, + balanceParams, + runtime.inlineFeeWarningData, + actions, + hasFeeProblem, + onToggleHideBalance, + ) + } - is SendResult.SentButQueued -> { - HudHelper.showWarningMessage(view, R.string.send_success_queued) - } + NetworkFeeWarningOverlay( + feeWarningData = viewModel.feeWarningData, + onConfirm = viewModel::onFeeWarningConfirmed, + onCancel = viewModel::onFeeWarningCancelled, + ) +} - is SendResult.Failed -> { - HudHelper.showErrorMessage(view, sendResult.caution.getString()) - } +@Composable +private fun swapConfirmViewModel( + navigation: SwapConfirmNavigation, + params: SwapConfirmQuoteParams, +): SwapConfirmViewModel { + val backStackEntry = remember { navigation.swap.currentBackStackEntry } + return viewModel( + viewModelStoreOwner = requireNotNull(backStackEntry), + factory = SwapConfirmViewModel.provideFactory( + quote = params.quote, + settings = params.settings, + navController = navigation.fragment, + direction = params.direction, + requestedAmountOut = params.requestedAmountOut, + multiSwapLegInfo = params.multiSwapLegInfo, + ), + ) +} +@Composable +private fun SwapResultEffects( + viewModel: SwapConfirmViewModel, + navigation: SwapConfirmNavigation, + multiSwapLegInfo: MultiSwapLegInfo?, +) { + val view = LocalView.current + val sendResult = viewModel.sendResult + var currentSnackbar by remember { mutableStateOf(null) } + + // Handle send result UI - must be in Composable context for getString() + currentSnackbar = when (sendResult) { + SendResult.Sending -> HudHelper.showInProcessMessage( + view, + R.string.Swap_Swapping, + SnackbarDuration.INDEFINITE, + ) + is SendResult.Sent -> HudHelper.showSuccessMessage(view, R.string.Hud_Text_Done) + is SendResult.SentButQueued -> HudHelper.showWarningMessage(view, R.string.send_success_queued) + is SendResult.Failed -> HudHelper.showErrorMessage(view, sendResult.caution.getString()) null -> { currentSnackbar?.dismiss() null @@ -132,234 +199,295 @@ fun SwapConfirmScreen( // Handle navigation after success LaunchedEffect(sendResult) { - if (sendResult is SendResult.Sent || sendResult is SendResult.SentButQueued) { - delay(1200) - val multiSwapId = viewModel.completedMultiSwapId - if (multiSwapId != null && multiSwapLegInfo is MultiSwapLegInfo.Leg1) { - fragmentNavController.popBackStack(R.id.multiswap, inclusive = true) - fragmentNavController.slideFromRight( + if (sendResult !is SendResult.Sent && sendResult !is SendResult.SentButQueued) return@LaunchedEffect + delay(1200) + val multiSwapId = viewModel.completedMultiSwapId + when { + multiSwapId != null && multiSwapLegInfo is MultiSwapLegInfo.Leg1 -> { + navigation.fragment.popBackStack(R.id.multiswap, inclusive = true) + navigation.fragment.slideFromRight( R.id.multiSwapExchanges, MultiSwapExchangesFragment.ARG_PENDING_MULTI_SWAP_ID to multiSwapId, ) - } else if (multiSwapLegInfo is MultiSwapLegInfo.Leg2) { - fragmentNavController.popBackStack(R.id.multiSwapExchanges, inclusive = true) - } else { - fragmentNavController.navigateUp() } + multiSwapLegInfo is MultiSwapLegInfo.Leg2 -> + navigation.fragment.popBackStack(R.id.multiSwapExchanges, inclusive = true) + else -> navigation.fragment.navigateUp() } } +} - ConfirmTransactionScreen( - onClickBack = swapNavController::navigateUpSafely, - onClickSettings = if (uiState.isAdvancedSettingsAvailable && onOpenSettings != null) { - { onOpenSettings.invoke() } - } else { - null - }, - onClickClose = null, - buttonsSlot = { - val hasErrorCaution = uiState.cautions.any { it.type == CautionViewItem.Type.Error } - if (uiState.loading) { - ButtonPrimaryYellow( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.Alert_Loading), - enabled = false, - onClick = { }, - ) - VSpacer(height = 12.dp) - subhead1_leah(text = stringResource(id = R.string.SwapConfirm_FetchingFinalQuote)) - } else if (uiState.criticalError != null) { - ButtonPrimaryDefault( +@Composable +private fun SwapConfirmButtons( + uiState: SwapConfirmUiState, + runtime: SwapConfirmRuntime, + actions: SwapConfirmActions, + hasFeeProblem: Boolean, +) { + val hasErrorCaution = uiState.cautions.any { it.type == CautionViewItem.Type.Error } + when { + uiState.loading -> SwapLoadingButton() + uiState.criticalError != null -> RefreshSwapButton(uiState.criticalError, actions.refresh) + !uiState.validQuote -> InvalidQuoteButton(uiState, hasErrorCaution, actions) + uiState.expired -> ExpiredQuoteButton(actions.refresh) + else -> ReadySwapButton(uiState, runtime, actions, hasFeeProblem, hasErrorCaution) + } +} + +@Composable +private fun SwapLoadingButton() { + ButtonPrimaryYellow( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.Alert_Loading), + enabled = false, + onClick = {}, + ) + VSpacer(height = 12.dp) + subhead1_leah(text = stringResource(R.string.SwapConfirm_FetchingFinalQuote)) +} + +@Composable +private fun RefreshSwapButton(title: String, onRefresh: () -> Unit) { + ButtonPrimaryDefault(modifier = Modifier.fillMaxWidth(), title = title, onClick = onRefresh) + VSpacer(height = 12.dp) +} + +@Composable +private fun InvalidQuoteButton( + uiState: SwapConfirmUiState, + hasErrorCaution: Boolean, + actions: SwapConfirmActions, +) { + val title = if (uiState.reapprovalRequired) R.string.swap_reapprove_action else R.string.Button_Refresh + ButtonPrimaryDefault( + modifier = Modifier.fillMaxWidth(), + title = stringResource(title), + onClick = if (uiState.reapprovalRequired) actions.reapprove else actions.refresh, + ) + VSpacer(height = 12.dp) + // A concrete estimation error is already shown by Cautions in the scrollable content; fall + // back to the generic text only when there is none, so the real reason is not obscured. + if (!hasErrorCaution) { + subhead1_leah(text = stringResource(R.string.SwapConfirm_QuoteIsInvalid)) + } +} + +@Composable +private fun ExpiredQuoteButton(onRefresh: () -> Unit) { + RefreshSwapButton(stringResource(R.string.Button_Refresh), onRefresh) + subhead1_leah(text = stringResource(R.string.SwapConfirm_QuoteExpired)) +} + +@Composable +private fun ReadySwapButton( + uiState: SwapConfirmUiState, + runtime: SwapConfirmRuntime, + actions: SwapConfirmActions, + hasFeeProblem: Boolean, + hasErrorCaution: Boolean, +) { + Column { + AdapterStatus(runtime, actions.retryAdapter) + // Disable button during swap and navigation delay (allow retry only on Failed). + val swapInProgress = runtime.sendResult?.let { it !is SendResult.Failed } == true + ButtonPrimaryYellow( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.Swap), + enabled = isSwapConfirmButtonEnabled( + isSynced = runtime.isSynced, + swapInProgress = swapInProgress, + hasRequiredQuoteData = uiState.amountOut != null && uiState.networkFee != null, + hasBlockingFeeState = hasFeeProblem, + hasErrorCaution = hasErrorCaution, + ), + onClick = actions.send, + ) + uiState.expiresIn?.let { + VSpacer(height = 12.dp) + subhead1_leah(text = stringResource(R.string.SwapConfirm_QuoteExpiresIn, it)) + } + } +} + +@Composable +private fun AdapterStatus(runtime: SwapConfirmRuntime, onRetry: () -> Unit) { + Column { + when { + runtime.hasAdapterError -> { + TextImportantWarning( modifier = Modifier.fillMaxWidth(), - title = uiState.criticalError, - onClick = { - viewModel.refresh() - }, + text = stringResource(R.string.send_confirmation_sync_error_warning), ) - VSpacer(height = 12.dp) - } else if (!uiState.validQuote) { + VSpacer(height = 8.dp) ButtonPrimaryDefault( modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.Button_Refresh), - onClick = viewModel::refresh + title = stringResource(R.string.Button_Retry), + onClick = onRetry, ) VSpacer(height = 12.dp) - // A concrete estimation error is already shown by Cautions in the scrollable - // content; fall back to the generic text only when there is none, so the user is - // not shown a vague "quote invalid" line on top of the real, specific reason. - if (!hasErrorCaution) { - subhead1_leah(text = stringResource(id = R.string.SwapConfirm_QuoteIsInvalid)) - } - } else if (uiState.expired) { - ButtonPrimaryDefault( + } + !runtime.isSynced -> { + TextImportantWarning( modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.Button_Refresh), - onClick = { - viewModel.refresh() - }, + text = stringResource(R.string.send_confirmation_syncing_warning), ) VSpacer(height = 12.dp) - subhead1_leah(text = stringResource(id = R.string.SwapConfirm_QuoteExpired)) - } else { - when { - viewModel.hasAdapterError -> { - TextImportantWarning( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.send_confirmation_sync_error_warning) - ) - VSpacer(height = 8.dp) - ButtonPrimaryDefault( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.Button_Retry), - onClick = viewModel::retryAdapterSync - ) - VSpacer(height = 12.dp) - } - !viewModel.isSynced -> { - TextImportantWarning( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.send_confirmation_syncing_warning) - ) - VSpacer(height = 12.dp) - } - } - // Disable button during swap and navigation delay (allow retry only on Failed) - val swapInProgress = sendResult != null && sendResult !is SendResult.Failed - ButtonPrimaryYellow( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.Swap), - enabled = isSwapConfirmButtonEnabled( - isSynced = viewModel.isSynced, - swapInProgress = swapInProgress, - hasRequiredQuoteData = uiState.amountOut != null && uiState.networkFee != null, - hasBlockingFeeState = hasFeeProblem, - hasErrorCaution = hasErrorCaution, - ), - onClick = viewModel::onClickSendWithWarningCheck, - ) - if (uiState.expiresIn != null) { - VSpacer(height = 12.dp) - subhead1_leah(text = stringResource(R.string.SwapConfirm_QuoteExpiresIn, uiState.expiresIn)) - } - } - } - ) { - SectionUniversalLawrence { - TokenRow( - token = uiState.tokenIn, - amount = uiState.amountIn, - fiatAmount = uiState.fiatAmountIn, - currency = uiState.currency, - borderTop = false, - title = stringResource(R.string.Send_Confirmation_YouSend), - amountColor = ComposeAppTheme.colors.leah, - ) - TokenRow( - token = uiState.tokenOut, - amount = uiState.amountOut, - fiatAmount = uiState.fiatAmountOut, - currency = uiState.currency, - title = stringResource(R.string.Swap_ToAmountTitle), - amountColor = ComposeAppTheme.colors.remus, - ) - } - uiState.amountOut?.let { amountOut -> - VSpacer(height = 16.dp) - SectionUniversalLawrence { - PriceField( - uiState.tokenIn, - uiState.tokenOut, - uiState.amountIn, - amountOut - ) - PriceImpactField( - uiState.priceImpact, - uiState.priceImpactLevel, - ) - uiState.amountOutMin?.let { amountOutMin -> - val subvalue = uiState.fiatAmountOutMin?.let { fiatAmountOutMin -> - CurrencyValue(uiState.currency, fiatAmountOutMin).getFormattedFull() - } ?: "---" - - SwapInfoRow( - borderTop = true, - title = stringResource(id = R.string.Swap_MinimumReceived), - value = CoinValue(uiState.tokenOut, amountOutMin).getFormattedFull(), - subvalue = subvalue - ) - } - provider?.let { p -> - SwapProviderField( - title = p.title, - iconId = p.icon - ) - } - uiState.quoteFields.forEach { - it.GetContent(fragmentNavController, true) - } } } + } +} - val transactionFields = uiState.transactionFields - if (transactionFields.isNotEmpty()) { - VSpacer(height = 16.dp) - SectionUniversalLawrence { - transactionFields.forEachIndexed { index, field -> - field.GetContent(fragmentNavController, index != 0) - } - } - } +@Composable +private fun SwapConfirmContent( + uiState: SwapConfirmUiState, + navigation: SwapConfirmNavigation, + balanceParams: SwapConfirmBalanceParams, + inlineFeeWarningData: NetworkFeeWarningData?, + actions: SwapConfirmActions, + hasFeeProblem: Boolean, + onToggleHideBalance: () -> Unit, +) { + Column { + SwapAmountsSection(uiState) + SwapQuoteSection(uiState, balanceParams.provider, navigation.fragment) + SwapTransactionFields(uiState, navigation.fragment) + SwapConfirmFeeInfo( + uiState, + balanceParams, + inlineFeeWarningData, + hasFeeProblem, + onToggleHideBalance, + ) + MevProtectionSection(uiState, actions.toggleMevProtection) + if (uiState.cautions.isNotEmpty()) Cautions(cautions = uiState.cautions) + } +} - VSpacer(height = 16.dp) - FeeInfoSection( - tokenIn = uiState.tokenIn, - displayBalance = displayBalance, - balanceHidden = balanceHidden, - feeToken = feeToken, - feeCoinBalance = feeCoinBalance, - feePrimary = uiState.networkFee?.primary?.getFormattedPlain() ?: "---", - feeSecondary = uiState.networkFee?.secondary?.getFormattedPlain() ?: "---", - insufficientFeeBalance = hasFeeProblem, - onBalanceClicked = onToggleHideBalance, - feeWarningData = viewModel.inlineFeeWarningData, +@Composable +private fun SwapAmountsSection(uiState: SwapConfirmUiState) { + SectionUniversalLawrence { + TokenRow( + token = uiState.tokenIn, + amount = uiState.amountIn, + fiatAmount = uiState.fiatAmountIn, + currency = uiState.currency, + borderTop = false, + title = stringResource(R.string.Send_Confirmation_YouSend), + amountColor = ComposeAppTheme.colors.leah, + ) + TokenRow( + token = uiState.tokenOut, + amount = uiState.amountOut, + fiatAmount = uiState.fiatAmountOut, + currency = uiState.currency, + title = stringResource(R.string.Swap_ToAmountTitle), + amountColor = ComposeAppTheme.colors.remus, ) + } +} - if (uiState.mevProtectionAvailable) { - VSpacer(16.dp) - SectionUniversalLawrence { - CellUniversal { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = R.drawable.ic_shield_24), - contentDescription = null, - tint = ComposeAppTheme.colors.jacob - ) - HSpacer(width = 16.dp) - body_leah(text = stringResource(R.string.mev_protection)) - HFillSpacer(minWidth = 8.dp) - HsSwitch( - checked = uiState.mevProtectionEnabled, - onCheckedChange = { - viewModel.toggleMevProtection(it) - } - ) - } - } +@Composable +private fun SwapQuoteSection( + uiState: SwapConfirmUiState, + provider: IMultiSwapProvider?, + navController: NavController, +) { + val amountOut = uiState.amountOut ?: return + VSpacer(height = 16.dp) + SectionUniversalLawrence { + PriceField(uiState.tokenIn, uiState.tokenOut, uiState.amountIn, amountOut) + PriceImpactField(uiState.priceImpact, uiState.priceImpactLevel) + uiState.amountOutMin?.let { + val fiat = uiState.fiatAmountOutMin?.let { value -> + CurrencyValue(uiState.currency, value).getFormattedFull() + } ?: "---" + SwapInfoRow( + borderTop = true, + title = stringResource(R.string.Swap_MinimumReceived), + value = CoinValue(uiState.tokenOut, it).getFormattedFull(), + subvalue = fiat, + ) } + provider?.let { SwapProviderField(title = it.title, iconId = it.icon) } + uiState.quoteFields.forEach { it.GetContent(navController, true) } + } +} - - if (uiState.cautions.isNotEmpty()) { - Cautions(cautions = uiState.cautions) +@Composable +private fun SwapTransactionFields(uiState: SwapConfirmUiState, navController: NavController) { + if (uiState.transactionFields.isEmpty()) return + VSpacer(height = 16.dp) + SectionUniversalLawrence { + uiState.transactionFields.forEachIndexed { index, field -> + field.GetContent(navController, index != 0) } } +} - NetworkFeeWarningOverlay( - feeWarningData = viewModel.feeWarningData, - onConfirm = viewModel::onFeeWarningConfirmed, - onCancel = viewModel::onFeeWarningCancelled, +@Composable +private fun SwapConfirmFeeInfo( + uiState: SwapConfirmUiState, + balance: SwapConfirmBalanceParams, + inlineFeeWarningData: NetworkFeeWarningData?, + hasFeeProblem: Boolean, + onToggleHideBalance: () -> Unit, +) { + VSpacer(height = 16.dp) + FeeInfoSection( + tokenIn = uiState.tokenIn, + displayBalance = balance.displayBalance, + balanceHidden = balance.balanceHidden, + feeToken = balance.feeToken, + feeCoinBalance = balance.feeCoinBalance, + feePrimary = uiState.networkFee?.primary?.getFormattedPlain() ?: "---", + feeSecondary = uiState.networkFee?.secondary?.getFormattedPlain() ?: "---", + insufficientFeeBalance = hasFeeProblem, + onBalanceClicked = onToggleHideBalance, + feeWarningData = inlineFeeWarningData, ) } +@Composable +private fun MevProtectionSection( + uiState: SwapConfirmUiState, + onToggleMevProtection: (Boolean) -> Unit, +) { + if (!uiState.mevProtectionAvailable) return + VSpacer(16.dp) + SectionUniversalLawrence { + CellUniversal { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_shield_24), + contentDescription = null, + tint = ComposeAppTheme.colors.jacob, + ) + HSpacer(width = 16.dp) + body_leah(text = stringResource(R.string.mev_protection)) + HFillSpacer(minWidth = 8.dp) + HsSwitch( + checked = uiState.mevProtectionEnabled, + onCheckedChange = onToggleMevProtection, + ) + } + } +} + +private data class SwapConfirmActions( + val refresh: () -> Unit, + val reapprove: () -> Unit, + val retryAdapter: () -> Unit, + val send: () -> Unit, + val toggleMevProtection: (Boolean) -> Unit, +) + +private data class SwapConfirmRuntime( + val isSynced: Boolean, + val hasAdapterError: Boolean, + val sendResult: SendResult?, + val inlineFeeWarningData: NetworkFeeWarningData?, +) + internal fun hasSwapConfirmFeeProblem( hasInsufficientFeeBalance: Boolean, hasFeeCaution: Boolean, diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModel.kt index f0e7384f501..e1508878164 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModel.kt @@ -15,14 +15,18 @@ import cash.p.terminal.core.App import cash.p.terminal.core.HSCaution import cash.p.terminal.core.ILocalStorage import cash.p.terminal.core.ethereum.CautionViewItem +import cash.p.terminal.core.ethereum.toCautionViewItem import cash.p.terminal.core.getKoinInstance import cash.p.terminal.core.storage.PendingMultiSwapStorage import cash.p.terminal.core.storage.SwapProviderTransactionsStorage import cash.p.terminal.entities.PendingMultiSwap import cash.p.terminal.entities.SwapProviderTransaction import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.modules.multiswap.providers.IExactOutSwapProvider +import cash.p.terminal.modules.multiswap.providers.InsufficientAllowanceCaution import cash.p.terminal.modules.multiswap.providers.OffChainSwapProvider import cash.p.terminal.modules.multiswap.providers.isOffChain +import cash.p.terminal.modules.multiswap.providers.requiredInput import cash.p.terminal.modules.multiswap.sendtransaction.ISendTransactionService import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionResult @@ -61,21 +65,26 @@ import java.util.UUID import kotlin.coroutines.cancellation.CancellationException class SwapConfirmViewModel( - private val swapProvider: IMultiSwapProvider, - private val swapQuote: ISwapQuote, - private val swapSettings: Map, + private val request: SwapConfirmRequest, currencyManager: CurrencyManager, - private val fiatServiceIn: FiatService, - private val fiatServiceOut: FiatService, - private val fiatServiceOutMin: FiatService, + private val fiatServices: SwapConfirmFiatServices, val sendTransactionService: ISendTransactionService<*>, private val timerService: TimerService, private val priceImpactService: PriceImpactService, wallet: Wallet, adapterManager: IAdapterManager, private val dispatcherProvider: DispatcherProvider, - private val multiSwapLegInfo: MultiSwapLegInfo? = null, ) : BaseSendViewModel(wallet, adapterManager) { + private val swapProvider = request.provider + private val swapQuote = request.quote + private val swapSettings = request.settings + private val fiatServiceIn = fiatServices.input + private val fiatServiceOut = fiatServices.output + private val fiatServiceOutMin = fiatServices.outputMinimum + private val executionMode = request.executionMode + private val direction = request.direction + private val requestedAmountOut = request.requestedAmountOut + private val multiSwapLegInfo = request.multiSwapLegInfo private val accountId: String = wallet.account.id private val localStorage: ILocalStorage by inject(ILocalStorage::class.java) private val pendingMultiSwapStorage: PendingMultiSwapStorage by inject(PendingMultiSwapStorage::class.java) @@ -107,6 +116,8 @@ class SwapConfirmViewModel( private var amountOut: BigDecimal? = null private var amountOutMin: BigDecimal? = null + private var amountInMax: BigDecimal? = swapQuote.amountInMax + private var finalQuoteCautions: List = emptyList() private var quoteFields: List = listOf() private var criticalError: String? = null private var swapProviderTransaction: SwapProviderTransaction? = null @@ -190,7 +201,11 @@ class SwapConfirmViewModel( viewModelScope.launch { sendTransactionService.stateFlow.collectLatest { - if (it.availableBalance != null && it.availableBalance < amountIn) { + if ( + direction == SwapAmountDirection.In && + it.availableBalance != null && + it.availableBalance < amountIn + ) { amountIn = it.availableBalance fiatServiceIn.setAmount(amountIn) refresh() @@ -210,22 +225,7 @@ class SwapConfirmViewModel( } override fun createState(): SwapConfirmUiState { - var cautions = sendTransactionState.cautions - - if (cautions.isEmpty()) { - priceImpactState.priceImpactCaution?.let { hsCaution -> - cautions = listOf( - CautionViewItem( - hsCaution.s.toString(), - hsCaution.description.toString(), - when (hsCaution.type) { - HSCaution.Type.Error -> CautionViewItem.Type.Error - HSCaution.Type.Warning -> CautionViewItem.Type.Warning - } - ) - ) - } - } + val cautions = buildCautions() return SwapConfirmUiState( expiresIn = timerState.remaining, @@ -234,6 +234,7 @@ class SwapConfirmViewModel( tokenIn = tokenIn, tokenOut = tokenOut, amountIn = amountIn, + amountInMax = amountInMax, amountOut = amountOut, amountOutMin = amountOutMin, fiatAmountIn = fiatAmountIn, @@ -243,7 +244,8 @@ class SwapConfirmViewModel( networkFee = sendTransactionState.networkFee, cautions = cautions, feeCaution = sendTransactionState.feeCaution, - validQuote = isSendable(), + validQuote = isSendable(cautions), + reapprovalRequired = finalQuoteCautions.any { it is InsufficientAllowanceCaution }, priceImpact = priceImpactState.priceImpact, priceImpactLevel = priceImpactState.priceImpactLevel, quoteFields = quoteFields, @@ -255,8 +257,32 @@ class SwapConfirmViewModel( ) } - private fun isSendable(): Boolean { - return swapProvider.isOffChain || sendTransactionState.sendable + private fun buildCautions(): List { + val quoteCautions = finalQuoteCautions.map(HSCaution::toCautionViewItem) + val priceImpactCaution = listOfNotNull( + priceImpactState.priceImpactCaution?.toCautionViewItem() + ) + val balanceCaution = if (hasInsufficientInputBalance()) { + listOf( + HSCaution( + TranslatableString.ResString(R.string.Swap_ErrorInsufficientBalance), + HSCaution.Type.Error, + ).toCautionViewItem() + ) + } else { + emptyList() + } + return sendTransactionState.cautions + quoteCautions + priceImpactCaution + balanceCaution + } + + private fun isSendable(cautions: List = buildCautions()): Boolean { + val transactionSendable = swapProvider.isOffChain || sendTransactionState.sendable + return transactionSendable && cautions.none { it.type == CautionViewItem.Type.Error } + } + + private fun hasInsufficientInputBalance(): Boolean { + val availableBalance = sendTransactionState.availableBalance ?: return false + return requiredInput(amountIn, amountInMax) > availableBalance } private fun needUseTimer() = @@ -277,19 +303,14 @@ class SwapConfirmViewModel( fetchJob?.cancel() fetchJob = viewModelScope.launch(dispatcherProvider.io) { try { - val finalQuote = swapProvider.fetchFinalQuote( - tokenIn = tokenIn, - tokenOut = tokenOut, - amountIn = amountIn, - swapSettings = swapSettings, - sendTransactionSettings = sendTransactionSettings, - swapQuote = swapQuote - ) + val finalQuote = fetchFinalQuoteByExecutionMode() amountIn = finalQuote.amountIn + amountInMax = finalQuote.amountInMax amountOut = finalQuote.amountOut amountOutMin = finalQuote.amountOutMin quoteFields = finalQuote.fields + finalQuoteCautions = finalQuote.cautions criticalError = null swapProviderTransaction = finalQuote.swapProviderTransaction @@ -314,6 +335,30 @@ class SwapConfirmViewModel( } } + private suspend fun fetchFinalQuoteByExecutionMode(): ISwapFinalQuote = + when (executionMode) { + SwapExecutionMode.ExactIn -> swapProvider.fetchFinalQuote( + tokenIn = tokenIn, + tokenOut = tokenOut, + amountIn = amountIn, + swapSettings = swapSettings, + sendTransactionSettings = sendTransactionSettings, + swapQuote = swapQuote, + ) + + SwapExecutionMode.NativeExactOut -> { + val exactOutProvider = swapProvider as IExactOutSwapProvider + exactOutProvider.fetchFinalQuoteExactOut( + tokenIn = tokenIn, + tokenOut = tokenOut, + amountOut = checkNotNull(requestedAmountOut), + swapSettings = swapSettings, + sendTransactionSettings = sendTransactionSettings, + swapQuote = swapQuote, + ) + } + } + private val BackendChangeNowResponseError.changeNowCriticalError: String get() = when (error) { BackendChangeNowResponseError.NOT_VALID_REFUND_ADDRESS -> { @@ -465,6 +510,8 @@ class SwapConfirmViewModel( quote: SwapProviderQuote, settings: Map, navController: NavController, + direction: SwapAmountDirection = SwapAmountDirection.In, + requestedAmountOut: BigDecimal? = null, multiSwapLegInfo: MultiSwapLegInfo? = null, ) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -474,72 +521,98 @@ class SwapConfirmViewModel( ): T { val wallet = App.walletManager.activeWallets .find { it.token == quote.tokenIn } - - val sendTransactionService = try { - checkNotNull(wallet) { "Wallet not found for ${quote.tokenIn}" } - SwapTransactionServiceFactory.create(quote.tokenIn, quote.provider) - } catch (e: Exception) { - Toast.makeText(App.instance, R.string.unsupported_token, Toast.LENGTH_SHORT) - .show() - navController.popBackStack() - - // Build a dummy service (sendable=false) so the ViewModel is - // inoperable while the screen navigates back. - object : ISendTransactionService(quote.tokenIn) { - override fun start(coroutineScope: CoroutineScope) = Unit - override suspend fun setSendTransactionData(data: SendTransactionData) = - Unit - - override fun hasSettings(): Boolean = false - - @Composable - override fun GetSettingsContent(navController: NavController) = Unit - override suspend fun sendTransaction(mevProtectionEnabled: Boolean): SendTransactionResult = - SendTransactionResult.Solana(SendResult.Sending) - - override val sendTransactionSettingsFlow: StateFlow - get() = MutableStateFlow( - SendTransactionSettings.Common - ) - - override fun createState(): SendTransactionServiceState = - SendTransactionServiceState( - availableBalance = null, - networkFee = null, - cautions = listOf(), - sendable = false, - loading = false, - fields = listOf(), - extraFees = mapOf() - ) - } - } + val sendTransactionService = createSendTransactionService(quote, wallet, navController) // When wallet is null the dummy service above (sendable=false) // prevents any swap execution while the screen navigates back. val assetFiatRateService: AssetFiatRateService = getKoinInstance() val dispatcherProvider: DispatcherProvider = getKoinInstance() return SwapConfirmViewModel( - swapProvider = quote.provider, - swapQuote = quote.swapQuote, - swapSettings = settings, + request = SwapConfirmRequest( + provider = quote.provider, + quote = quote.swapQuote, + settings = settings, + executionMode = quote.executionMode, + direction = direction, + requestedAmountOut = requestedAmountOut, + multiSwapLegInfo = multiSwapLegInfo, + ), currencyManager = App.currencyManager, - fiatServiceIn = FiatService(assetFiatRateService), - fiatServiceOut = FiatService(assetFiatRateService), - fiatServiceOutMin = FiatService(assetFiatRateService), + fiatServices = SwapConfirmFiatServices( + input = FiatService(assetFiatRateService), + output = FiatService(assetFiatRateService), + outputMinimum = FiatService(assetFiatRateService), + ), sendTransactionService = sendTransactionService, timerService = TimerService(), priceImpactService = PriceImpactService(), wallet = wallet ?: App.walletManager.activeWallets.first(), adapterManager = App.adapterManager, dispatcherProvider = dispatcherProvider, - multiSwapLegInfo = multiSwapLegInfo, ) as T } } + + private fun createSendTransactionService( + quote: SwapProviderQuote, + wallet: Wallet?, + navController: NavController, + ): ISendTransactionService<*> = try { + checkNotNull(wallet) { "Wallet not found for ${quote.tokenIn}" } + SwapTransactionServiceFactory.create(quote.tokenIn, quote.provider) + } catch (e: Exception) { + Toast.makeText(App.instance, R.string.unsupported_token, Toast.LENGTH_SHORT).show() + navController.popBackStack() + unavailableSendTransactionService(quote.tokenIn) + } + + // The dummy service keeps the ViewModel inoperable while the screen navigates back after + // a missing or unsupported wallet is detected. + private fun unavailableSendTransactionService(token: Token) = + object : ISendTransactionService(token) { + override fun start(coroutineScope: CoroutineScope) = Unit + override suspend fun setSendTransactionData(data: SendTransactionData) = Unit + override fun hasSettings(): Boolean = false + + @Composable + override fun GetSettingsContent(navController: NavController) = Unit + + override suspend fun sendTransaction( + mevProtectionEnabled: Boolean, + ): SendTransactionResult = SendTransactionResult.Solana(SendResult.Sending) + + override val sendTransactionSettingsFlow: StateFlow + get() = MutableStateFlow(SendTransactionSettings.Common) + + override fun createState() = SendTransactionServiceState( + availableBalance = null, + networkFee = null, + cautions = emptyList(), + sendable = false, + loading = false, + fields = emptyList(), + extraFees = emptyMap(), + ) + } } } +data class SwapConfirmRequest( + val provider: IMultiSwapProvider, + val quote: ISwapQuote, + val settings: Map, + val executionMode: SwapExecutionMode = SwapExecutionMode.ExactIn, + val direction: SwapAmountDirection = SwapAmountDirection.In, + val requestedAmountOut: BigDecimal? = null, + val multiSwapLegInfo: MultiSwapLegInfo? = null, +) + +data class SwapConfirmFiatServices( + val input: FiatService, + val output: FiatService, + val outputMinimum: FiatService, +) + sealed class MultiSwapLegInfo { data class Leg1( val coinUidIn: String, @@ -567,6 +640,7 @@ data class SwapConfirmUiState( val tokenIn: Token, val tokenOut: Token, val amountIn: BigDecimal, + val amountInMax: BigDecimal?, val amountOut: BigDecimal?, val amountOutMin: BigDecimal?, val fiatAmountIn: BigDecimal?, @@ -575,13 +649,14 @@ data class SwapConfirmUiState( val currency: Currency, val networkFee: SendModule.AmountData?, val cautions: List, - val feeCaution: CautionViewItem? = null, + val feeCaution: CautionViewItem?, val validQuote: Boolean, + val reapprovalRequired: Boolean, val priceImpact: BigDecimal?, val priceImpactLevel: PriceImpactLevel?, val quoteFields: List, val transactionFields: List, - val criticalError: String? = null, + val criticalError: String?, var isAdvancedSettingsAvailable: Boolean, val mevProtectionAvailable: Boolean, val mevProtectionEnabled: Boolean, diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt index 45ea2929534..601867f485d 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt @@ -15,14 +15,15 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,10 +40,14 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow @@ -55,6 +60,7 @@ import androidx.navigation.NavController import cash.p.terminal.R import cash.p.terminal.entities.CoinValue import cash.p.terminal.modules.multiswap.action.ActionCreate +import cash.p.terminal.modules.multiswap.action.ISwapProviderAction import cash.p.terminal.modules.fee.FeeInfoSection import cash.p.terminal.modules.fee.QuoteInfoRow import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider @@ -82,7 +88,6 @@ import cash.p.terminal.ui_compose.components.TextImportantWarning import cash.p.terminal.ui_compose.components.VSpacer import cash.p.terminal.ui_compose.components.body_grey import cash.p.terminal.ui_compose.components.headline1_grey -import cash.p.terminal.ui_compose.components.headline1_leah import cash.p.terminal.ui_compose.components.micro_grey import cash.p.terminal.ui_compose.components.subhead1_jacob import cash.p.terminal.ui_compose.components.subhead1_leah @@ -101,9 +106,11 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import cash.p.terminal.core.App +import cash.p.terminal.core.ethereum.toCautionViewItem import cash.p.terminal.core.getKoinInstance import cash.p.terminal.core.tryOrNull import cash.p.terminal.modules.multiswap.providers.SwapProvidersRepository +import cash.p.terminal.modules.evmfee.Cautions import cash.p.terminal.modules.multiswap.providersettings.SwapProvidersSettingsScreen import cash.p.terminal.modules.multiswap.providersettings.SwapProvidersSettingsViewModel import cash.p.terminal.strings.helpers.TranslatableString @@ -179,7 +186,7 @@ private data class PayCoreVerificationPage( ) @Serializable -private data class PayCorePaymentPage( +internal data class PayCorePaymentPage( val amountIn: String, val amountOut: String, val serviceFee: String, @@ -188,11 +195,15 @@ private data class PayCorePaymentPage( val tokenOutUid: String, val blockchainTypeIn: String, val blockchainTypeOut: String, + val direction: SwapAmountDirection, + val requestedAmountOut: String?, ) @Serializable private enum class SwapCoinDirection { From, To } +private enum class SwapInputFocus { None, AmountIn, FiatAmountIn, AmountOut, FiatAmountOut } + @Composable fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) { val viewModel = viewModel( @@ -214,14 +225,8 @@ fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) composablePopup { backStackEntry -> val args = backStackEntry.toRoute() val direction = args.direction - val otherToken = when (direction) { - SwapCoinDirection.From -> viewModel.uiState.tokenOut - SwapCoinDirection.To -> viewModel.uiState.tokenIn - } - val titleResId = when (direction) { - SwapCoinDirection.From -> R.string.Swap_YouPay - SwapCoinDirection.To -> R.string.Swap_YouGet - } + val otherToken = viewModel.uiState.otherToken(direction) + val titleResId = direction.titleResId() SwapSelectCoinScreen( navController = swapNavController, @@ -245,7 +250,7 @@ fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) } val selectProviderViewModel = viewModel( viewModelStoreOwner = backStackEntry, - factory = SwapSelectProviderViewModel.Factory(quotes) + factory = SwapSelectProviderViewModel.Factory(quotes, viewModel.uiState.direction) ) val swapProvidersRepository = remember { getKoinInstance() } val disabledIds by swapProvidersRepository.disabledIds.collectAsStateWithLifecycle() @@ -276,18 +281,26 @@ fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) } val settings = remember { viewModel.getSettings() } SwapConfirmScreen( - fragmentNavController = navController, - swapNavController = swapNavController, - quote = quote, - settings = settings, - provider = viewModel.uiState.quote?.provider, - displayBalance = viewModel.uiState.displayBalance, - balanceHidden = viewModel.uiState.balanceHidden, - feeToken = viewModel.uiState.feeToken, - feeCoinBalance = viewModel.uiState.feeCoinBalance, + navigation = SwapConfirmNavigation(navController, swapNavController), + quoteParams = SwapConfirmQuoteParams( + quote = quote, + settings = settings, + direction = viewModel.uiState.direction, + requestedAmountOut = viewModel.uiState.requestedAmountOut, + multiSwapLegInfo = multiSwapLegInfo, + ), + balanceParams = SwapConfirmBalanceParams( + provider = viewModel.uiState.quote?.provider, + displayBalance = viewModel.uiState.displayBalance, + balanceHidden = viewModel.uiState.balanceHidden, + feeToken = viewModel.uiState.feeToken, + feeCoinBalance = viewModel.uiState.feeCoinBalance, + ), onToggleHideBalance = viewModel::toggleHideBalance, + onReapprove = { + if (swapNavController.navigateUpSafely()) viewModel.reQuote() + }, onOpenSettings = { swapNavController.navigate(SwapTransactionSettingsPage) }, - multiSwapLegInfo = multiSwapLegInfo, ) } composablePage { @@ -331,19 +344,20 @@ fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) return@composablePage } val paymentParams = remember(args, tokenOut) { - PayCorePaymentParams( - amountIn = args.amountIn.toBigDecimal(), - amountOut = args.amountOut.toBigDecimal(), - networkType = args.networkType, - tokenInUid = args.tokenInUid, - tokenOutUid = args.tokenOutUid, - blockchainTypeIn = args.blockchainTypeIn, - blockchainTypeOut = args.blockchainTypeOut, - addressOut = tryOrNull { getKoinInstance().getReceiveAddress(tokenOut) }.orEmpty(), + args.toPaymentParams( + addressOut = tryOrNull { + getKoinInstance().getReceiveAddress(tokenOut) + }.orEmpty(), ) } val paymentViewModel = koinViewModel( - key = args.amountIn + args.networkType + key = listOf( + args.amountIn, + args.amountOut, + args.direction, + args.requestedAmountOut, + args.networkType, + ).joinToString("|"), ) { parametersOf(paymentParams) } PayCorePaymentScreen( uiState = paymentViewModel.uiState, @@ -370,7 +384,7 @@ private fun buildNextPage(viewModel: SwapViewModel): Any { return buildPayCorePaymentPage(viewModel.uiState) ?: SwapConfirmPage } -private fun buildPayCorePaymentPage(uiState: SwapUiState): PayCorePaymentPage? { +internal fun buildPayCorePaymentPage(uiState: SwapUiState): PayCorePaymentPage? { val quote = uiState.quote val tokenIn = uiState.tokenIn if (quote == null || tokenIn == null) return null @@ -390,14 +404,39 @@ private fun buildPayCorePaymentPage(uiState: SwapUiState): PayCorePaymentPage? { tokenInUid = tokenIn.coin.uid, tokenOutUid = tokenOut.coin.uid, blockchainTypeIn = tokenIn.blockchainType.uid, - blockchainTypeOut = tokenOut.blockchainType.uid + blockchainTypeOut = tokenOut.blockchainType.uid, + direction = uiState.direction, + requestedAmountOut = uiState.requestedAmountOut?.toPlainString(), ) } +internal fun PayCorePaymentPage.toPaymentParams(addressOut: String) = PayCorePaymentParams( + amountIn = amountIn.toBigDecimal(), + amountOut = amountOut.toBigDecimal(), + networkType = networkType, + tokenInUid = tokenInUid, + tokenOutUid = tokenOutUid, + blockchainTypeIn = blockchainTypeIn, + blockchainTypeOut = blockchainTypeOut, + addressOut = addressOut, + direction = direction, + requestedAmountOut = requestedAmountOut?.toBigDecimal(), +) + private fun payCoreQuoteServiceFee(quote: SwapProviderQuote): BigDecimal { return (quote.swapQuote as? PayCoreQuote)?.serviceFee ?: BigDecimal.ZERO } +private fun SwapUiState.otherToken(direction: SwapCoinDirection): Token? = when (direction) { + SwapCoinDirection.From -> tokenOut + SwapCoinDirection.To -> tokenIn +} + +private fun SwapCoinDirection.titleResId(): Int = when (this) { + SwapCoinDirection.From -> R.string.Swap_YouPay + SwapCoinDirection.To -> R.string.Swap_YouGet +} + private fun buildMultiSwapLeg1Info(viewModel: SwapViewModel): MultiSwapLegInfo? { val route = viewModel.uiState.multiSwapRoute ?: return null val uiState = viewModel.uiState @@ -444,49 +483,11 @@ private fun SwapMainScreen( } } - SwapScreenInner( - uiState = uiState, - timeRemainingProgress = { viewModel.timeRemainingProgress }, - onClickClose = fragmentNavController::navigateUpSafely, - onClickCoinFrom = { - swapNavController.navigate(SwapSelectCoinPage(SwapCoinDirection.From)) - }, - onClickCoinTo = { - swapNavController.navigate(SwapSelectCoinPage(SwapCoinDirection.To)) - }, - onSwitchPairs = viewModel::onSwitchPairs, - onEnterAmount = viewModel::onEnterAmount, - onEnterAmountPercentage = viewModel::onEnterAmountPercentage, - onEnterFiatAmount = viewModel::onEnterFiatAmount, - onClickProvider = { - swapNavController.navigate(SwapSelectProviderPage) - }, - onClickProviderSettings = { - swapNavController.navigate(SwapSettingsPage) - }, - onTimeout = viewModel::reQuote, - onClickNext = { - val nextPage = buildNextPage(viewModel) - swapNavController.navigate(nextPage) - }, - onCreateMissingTokens = { tokens -> - tokens.forEach { token -> - manageWalletsViewModel.enable(token) - } - if (manageWalletsViewModel.showScanToAddButton) { - manageWalletsViewModel.requestScanToAddTokens(false) - } - viewModel.createMissingTokens(tokens) - }, - onActionStarted = { - viewModel.onActionStarted() - }, - onActionCompleted = { - viewModel.onActionCompleted() - }, - navController = fragmentNavController, - onBalanceClicked = viewModel::toggleHideBalance, - onOpenVerification = { + val openSettings = remember(swapNavController) { + { swapNavController.navigate(SwapSettingsPage) } + } + val openVerification = remember(swapNavController) { + { val tokenIn = viewModel.uiState.tokenIn val tokenOut = viewModel.uiState.tokenOut val usdtToken = if (tokenIn != null && PayCoreAssets.isRub(tokenIn)) tokenOut else tokenIn @@ -497,305 +498,395 @@ private fun SwapMainScreen( if (networkType != null && !walletAddress.isNullOrBlank()) { swapNavController.navigate(PayCoreVerificationPage(networkType, walletAddress)) } - }, + } + } + val controller = remember(viewModel, fragmentNavController, swapNavController, manageWalletsViewModel) { + SwapScreenController( + input = SwapInputController( + enterAmount = viewModel::onEnterAmount, + enterAmountOut = viewModel::onEnterAmountOut, + enterFiatAmount = viewModel::onEnterFiatAmount, + enterFiatAmountOut = viewModel::onEnterFiatAmountOut, + enterAmountPercentage = viewModel::onEnterAmountPercentage, + ), + navigation = SwapNavigationController( + close = fragmentNavController::navigateUpSafely, + selectCoinFrom = { swapNavController.navigate(SwapSelectCoinPage(SwapCoinDirection.From)) }, + selectCoinTo = { swapNavController.navigate(SwapSelectCoinPage(SwapCoinDirection.To)) }, + openProvider = { swapNavController.navigate(SwapSelectProviderPage) }, + openSettings = openSettings, + ), + operations = SwapOperationsController( + timeRemainingProgress = { viewModel.timeRemainingProgress }, + switchPairs = viewModel::onSwitchPairs, + refreshQuote = viewModel::reQuote, + proceed = { swapNavController.navigate(buildNextPage(viewModel)) }, + toggleBalance = viewModel::toggleHideBalance, + executeAction = { action, navController -> + viewModel.onActionStarted() + when (action) { + is ActionCreate -> { + action.tokensToAdd.forEach(manageWalletsViewModel::enable) + if (manageWalletsViewModel.showScanToAddButton) { + manageWalletsViewModel.requestScanToAddTokens(false) + } + viewModel.createMissingTokens(action.tokensToAdd) + } + is PayCoreSelectBankAction -> openSettings() + is PayCoreVerificationAction -> openVerification() + else -> action.execute(navController, viewModel::onActionCompleted) + } + }, + ), + ) + } + + SwapScreenInner( + uiState = uiState, + controller = controller, + navController = fragmentNavController, ) } +private class SwapScreenController( + private val input: SwapInputController, + private val navigation: SwapNavigationController, + private val operations: SwapOperationsController, +) { + val timeRemainingProgress: Float? + get() = operations.timeRemainingProgress() + + fun close() = navigation.close() + fun selectCoinFrom() = navigation.selectCoinFrom() + fun selectCoinTo() = navigation.selectCoinTo() + fun switchPairs() = operations.switchPairs() + fun enterAmount(amount: BigDecimal?) = input.enterAmount(amount) + fun enterAmountOut(amount: BigDecimal?) = input.enterAmountOut(amount) + fun enterFiatAmount(amount: BigDecimal?) = input.enterFiatAmount(amount) + fun enterFiatAmountOut(amount: BigDecimal?) = input.enterFiatAmountOut(amount) + fun enterAmountPercentage(percentage: Int) = input.enterAmountPercentage(percentage) + fun openProvider() = navigation.openProvider() + fun openSettings() = navigation.openSettings() + val refreshQuote: () -> Unit + get() = operations.refreshQuote + fun proceed() = operations.proceed() + fun toggleBalance() = operations.toggleBalance() + + fun executeAction(action: ISwapProviderAction, navController: NavController) { + operations.executeAction(action, navController) + } +} + +private data class SwapInputController( + val enterAmount: (BigDecimal?) -> Unit, + val enterAmountOut: (BigDecimal?) -> Unit, + val enterFiatAmount: (BigDecimal?) -> Unit, + val enterFiatAmountOut: (BigDecimal?) -> Unit, + val enterAmountPercentage: (Int) -> Unit, +) + +private data class SwapNavigationController( + val close: () -> Unit, + val selectCoinFrom: () -> Unit, + val selectCoinTo: () -> Unit, + val openProvider: () -> Unit, + val openSettings: () -> Unit, +) + +private data class SwapOperationsController( + val timeRemainingProgress: () -> Float?, + val switchPairs: () -> Unit, + val refreshQuote: () -> Unit, + val proceed: () -> Unit, + val toggleBalance: () -> Unit, + val executeAction: (ISwapProviderAction, NavController) -> Unit, +) + @Composable private fun SwapScreenInner( uiState: SwapUiState, - timeRemainingProgress: () -> Float?, - onClickClose: () -> Unit, - onClickCoinFrom: () -> Unit, - onClickCoinTo: () -> Unit, - onSwitchPairs: () -> Unit, - onEnterAmount: (BigDecimal?) -> Unit, - onEnterFiatAmount: (BigDecimal?) -> Unit, - onEnterAmountPercentage: (Int) -> Unit, - onClickProvider: () -> Unit, - onClickProviderSettings: () -> Unit, - onTimeout: () -> Unit, - onClickNext: () -> Unit, - onCreateMissingTokens: (Set) -> Unit, - onActionStarted: () -> Unit, - onActionCompleted: () -> Unit, - onBalanceClicked: () -> Unit, + controller: SwapScreenController, navController: NavController, - onOpenVerification: () -> Unit ) { LifecycleResumeEffect(uiState.timeout) { if (uiState.timeout) { - onTimeout.invoke() + controller.refreshQuote() } onPauseOrDispose { } } - val quote = uiState.quote - Scaffold( - topBar = { - AppBar( - title = stringResource(R.string.Swap), - navigationIcon = { - HsBackButton(onClick = onClickClose) - }, - menuItems = buildList { - timeRemainingProgress()?.let { progress -> - add(MenuItemTimeoutIndicator(progress)) - } - if (quote?.swapQuote?.settings?.isNotEmpty() == true) { - add( - MenuItem( - title = TranslatableString.ResString(R.string.SwapSettings_Title), - icon = R.drawable.ic_manage_2_24, - onClick = onClickProviderSettings, - ) - ) - } - } - ) - }, + topBar = { SwapAppBar(uiState, controller) }, containerColor = ComposeAppTheme.colors.tyler, - ) { - val focusManager = LocalFocusManager.current + ) { contentPadding -> val keyboardState by observeKeyboardState() - var amountInputHasFocus by remember { mutableStateOf(false) } + var inputFocus by remember { mutableStateOf(SwapInputFocus.None) } + Box(modifier = Modifier.fillMaxSize()) { + SwapScreenContent( + uiState = uiState, + controller = controller, + navController = navController, + modifier = Modifier.padding(contentPadding), + onFocusChange = { focus, state -> + inputFocus = if (state.isFocused) focus else SwapInputFocus.None + }, + ) + SwapSuggestions( + uiState = uiState, + controller = controller, + inputFocus = inputFocus, + keyboardOpen = keyboardState == Keyboard.Opened, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} - Box( - modifier = Modifier - .fillMaxSize() - ) { - Column( - modifier = Modifier - .padding(it) - .imePadding() - .verticalScroll(rememberScrollState()) - ) { - VSpacer(height = 12.dp) - SwapInput( - amountIn = uiState.amountIn, - fiatAmountIn = uiState.fiatAmountIn, - fiatAmountInputEnabled = uiState.fiatAmountInputEnabled, - onSwitchPairs = onSwitchPairs, - amountOut = uiState.multiSwapRoute?.selectedLeg2Quote?.amountOut - ?: quote?.amountOut, - fiatAmountOut = uiState.fiatAmountOut, - fiatPriceImpact = uiState.fiatPriceImpact, - fiatPriceImpactLevel = uiState.fiatPriceImpactLevel, - onValueChange = onEnterAmount, - onFiatValueChange = onEnterFiatAmount, - onClickCoinFrom = onClickCoinFrom, - onClickCoinTo = onClickCoinTo, - tokenIn = uiState.tokenIn, - tokenOut = uiState.tokenOut, - currency = uiState.currency, - intermediateToken = uiState.multiSwapRoute?.intermediateCoin, - onFocusChanged = { - amountInputHasFocus = it.hasFocus - }, +@Composable +private fun SwapAppBar(uiState: SwapUiState, controller: SwapScreenController) { + AppBar( + title = stringResource(R.string.Swap), + navigationIcon = { HsBackButton(onClick = controller::close) }, + menuItems = buildList { + controller.timeRemainingProgress?.let { add(MenuItemTimeoutIndicator(it)) } + if (uiState.quote?.swapQuote?.settings?.isNotEmpty() == true) { + add( + MenuItem( + title = TranslatableString.ResString(R.string.SwapSettings_Title), + icon = R.drawable.ic_manage_2_24, + onClick = controller::openSettings, + ) ) + } + }, + ) +} - VSpacer(height = 12.dp) - - when (val currentStep = uiState.currentStep) { - is SwapStep.InputRequired -> { - val title = when (currentStep.inputType) { - InputType.TokenIn -> stringResource(R.string.Swap_SelectTokenIn) - InputType.TokenOut -> stringResource(R.string.Swap_SelectTokenOut) - InputType.Amount -> stringResource(R.string.Swap_EnterAmount) - } - - ButtonPrimaryYellow( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - title = title, - enabled = false, - onClick = {} - ) - } - - SwapStep.Quoting -> { - ButtonPrimaryYellow( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - title = stringResource(R.string.Swap_Quoting), - enabled = false, - loadingIndicator = true, - onClick = {} - ) - } - - is SwapStep.Error -> { - val errorText = when (val error = currentStep.error) { - SwapError.InsufficientBalanceFrom -> stringResource(id = R.string.Swap_ErrorInsufficientBalance) - is NoSupportedSwapProvider -> stringResource(id = R.string.Swap_ErrorNoProviders) - is NoEnabledSwapProvider -> stringResource(id = R.string.swap_no_enabled_providers) - is SwapRouteNotFound -> stringResource(id = R.string.Swap_ErrorNoQuote) - is SwapDepositTooSmall -> stringResource( - id = R.string.swap_out_of_min_amount, - error.minValue.toPlainString() - ) - is SwapAmountOutOfRange -> stringResource(id = R.string.swap_no_providers_for_this_amount) - - is PriceImpactTooHigh -> stringResource(id = R.string.Swap_ErrorHighPriceImpact) - is UnknownHostException -> stringResource(id = R.string.Hud_Text_NoInternet) - is WalletSyncing -> stringResource(id = R.string.Swap_ErrorWalletSyncing) - is WalletNotSynced -> stringResource(id = R.string.Swap_ErrorWalletNotSynced) - else -> error.message ?: error.javaClass.simpleName - } +@Composable +private fun SwapScreenContent( + uiState: SwapUiState, + controller: SwapScreenController, + navController: NavController, + modifier: Modifier = Modifier, + onFocusChange: (SwapInputFocus, FocusState) -> Unit = { _, _ -> }, +) { + Column( + modifier = modifier + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + VSpacer(height = 12.dp) + SwapInput(uiState, controller, onFocusChange) + VSpacer(height = 12.dp) + SwapStepButton(uiState, controller, navController) + VSpacer(height = 12.dp) + SwapFeeInfo(uiState, controller) + VSpacer(height = 12.dp) + SwapQuoteInfo(uiState, controller, navController) + SwapWarnings(uiState) + VSpacer(height = 32.dp) + } +} - ButtonPrimaryYellow( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - title = errorText, - enabled = false, - onClick = {} - ) - } +@Composable +private fun SwapStepButton( + uiState: SwapUiState, + controller: SwapScreenController, + navController: NavController, +) { + when (val currentStep = uiState.currentStep) { + is SwapStep.InputRequired -> DisabledSwapButton(inputRequiredTitle(currentStep.inputType)) + SwapStep.Quoting -> DisabledSwapButton(stringResource(R.string.Swap_Quoting), loading = true) + is SwapStep.Error -> DisabledSwapButton(swapErrorText(currentStep.error)) + is SwapStep.ActionRequired -> { + val action = currentStep.action + ButtonPrimaryDefault( + modifier = swapButtonModifier(), + title = if (action.inProgress) action.getTitleInProgress() else action.getTitle(), + enabled = !action.inProgress, + onClick = { controller.executeAction(action, navController) }, + ) + } + SwapStep.Proceed -> ButtonPrimaryYellow( + modifier = swapButtonModifier(), + title = stringResource(R.string.Swap_Proceed), + enabled = !uiState.insufficientFeeBalance, + onClick = controller::proceed, + ) + } +} - is SwapStep.ActionRequired -> { - val action = currentStep.action - val title = if (action.inProgress) { - action.getTitleInProgress() - } else { - action.getTitle() - } +@Composable +private fun DisabledSwapButton(title: String, loading: Boolean = false) { + ButtonPrimaryYellow( + modifier = swapButtonModifier(), + title = title, + enabled = false, + loadingIndicator = loading, + onClick = {}, + ) +} - ButtonPrimaryDefault( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - title = title, - enabled = !action.inProgress, - onClick = { - onActionStarted.invoke() - when (action) { - is ActionCreate -> onCreateMissingTokens(action.tokensToAdd) - is PayCoreSelectBankAction -> onClickProviderSettings() - is PayCoreVerificationAction -> onOpenVerification() - else -> action.execute(navController, onActionCompleted) - } - } - ) - } +private fun swapButtonModifier(): Modifier = + Modifier.padding(horizontal = 16.dp).fillMaxWidth() - SwapStep.Proceed -> { - ButtonPrimaryYellow( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - title = stringResource(R.string.Swap_Proceed), - enabled = !uiState.insufficientFeeBalance, - onClick = onClickNext - ) - } - } +@Composable +private fun inputRequiredTitle(inputType: InputType): String = stringResource( + when (inputType) { + InputType.TokenIn -> R.string.Swap_SelectTokenIn + InputType.TokenOut -> R.string.Swap_SelectTokenOut + InputType.Amount -> R.string.Swap_EnterAmount + } +) - VSpacer(height = 12.dp) - - val feeToken = uiState.feeToken - val networkFee = uiState.networkFee - FeeInfoSection( - tokenIn = uiState.tokenIn, - displayBalance = uiState.displayBalance, - balanceHidden = uiState.balanceHidden, - feeToken = feeToken, - feeCoinBalance = uiState.feeCoinBalance, - feePrimary = if (feeToken != null && networkFee != null) { - CoinValue(feeToken, networkFee).getFormattedFull() - } else { - "---" - }, - feeSecondary = uiState.networkFeeFiatAmount?.let { - App.numberFormatter.formatFiatFull(it, uiState.currency.symbol) - } ?: "", - insufficientFeeBalance = uiState.insufficientFeeBalance, - onBalanceClicked = onBalanceClicked, - feeTitle = stringResource(R.string.estimated_fee), - ) +@Composable +private fun swapErrorText(error: Throwable): String = when (error) { + SwapError.InsufficientBalanceFrom -> stringResource(R.string.Swap_ErrorInsufficientBalance) + is NoSupportedSwapProvider -> stringResource(R.string.Swap_ErrorNoProviders) + is NoEnabledSwapProvider -> stringResource(R.string.swap_no_enabled_providers) + is NoExactOutSwapProvider -> stringResource(R.string.Swap_ErrorNoQuote) + is SwapRouteNotFound -> stringResource(R.string.Swap_ErrorNoQuote) + is SwapDepositTooSmall -> stringResource(R.string.swap_out_of_min_amount, error.minValue.toPlainString()) + is SwapAmountOutOfRange -> stringResource(R.string.swap_no_providers_for_this_amount) + is PriceImpactTooHigh -> stringResource(R.string.Swap_ErrorHighPriceImpact) + is UnknownHostException -> stringResource(R.string.Hud_Text_NoInternet) + is WalletSyncing -> stringResource(R.string.Swap_ErrorWalletSyncing) + is WalletNotSynced -> stringResource(R.string.Swap_ErrorWalletNotSynced) + else -> error.message ?: error.javaClass.simpleName +} - VSpacer(height = 12.dp) - if (quote != null) { - CardsSwapInfo { - ProviderField( - swapProvider = quote.provider, - estimationTime = quote.estimationTime, - onClickProvider = onClickProvider, - ) - val finalTokenOut = uiState.tokenOut ?: quote.tokenOut - val finalAmountOut = uiState.multiSwapRoute?.selectedLeg2Quote?.amountOut ?: quote.amountOut - PriceField(quote.tokenIn, finalTokenOut, quote.amountIn, finalAmountOut) - PriceImpactField( - uiState.priceImpact, - uiState.priceImpactLevel, - ) - quote.fields.forEach { - it.GetContent(navController, true) - } - } - } +@Composable +private fun SwapFeeInfo(uiState: SwapUiState, controller: SwapScreenController) { + val feeToken = uiState.feeToken + val networkFee = uiState.networkFee + FeeInfoSection( + tokenIn = uiState.tokenIn, + displayBalance = uiState.displayBalance, + balanceHidden = uiState.balanceHidden, + feeToken = feeToken, + feeCoinBalance = uiState.feeCoinBalance, + feePrimary = if (feeToken != null && networkFee != null) { + CoinValue(feeToken, networkFee).getFormattedFull() + } else { + "---" + }, + feeSecondary = uiState.networkFeeFiatAmount?.let { + App.numberFormatter.formatFiatFull(it, uiState.currency.symbol) + }.orEmpty(), + insufficientFeeBalance = uiState.insufficientFeeBalance, + onBalanceClicked = controller::toggleBalance, + feeTitle = stringResource(R.string.estimated_fee), + ) +} - uiState.warningMessage?.let { warning -> - VSpacer(height = 12.dp) - TextImportantWarning( - modifier = Modifier.padding(horizontal = 16.dp), - text = warning.getString(), - icon = R.drawable.ic_attention_20 - ) - } +@Composable +private fun SwapQuoteInfo( + uiState: SwapUiState, + controller: SwapScreenController, + navController: NavController, +) { + val quote = uiState.quote ?: return + CardsSwapInfo { + ProviderField(quote.provider, quote.estimationTime, controller::openProvider) + val finalTokenOut = uiState.tokenOut ?: quote.tokenOut + val finalAmountOut = uiState.multiSwapRoute?.selectedLeg2Quote?.amountOut ?: quote.amountOut + PriceField(quote.tokenIn, finalTokenOut, quote.amountIn, finalAmountOut) + PriceImpactField(uiState.priceImpact, uiState.priceImpactLevel) + quote.fields.forEach { it.GetContent(navController, true) } + } +} - if (uiState.error is PriceImpactTooHigh) { - VSpacer(height = 12.dp) - TextImportantError( - modifier = Modifier.padding(horizontal = 16.dp), - icon = R.drawable.ic_attention_20, - title = stringResource(id = R.string.Swap_PriceImpact), - text = stringResource( - id = R.string.Swap_PriceImpactTooHigh, - uiState.error.providerTitle ?: "" - ) - ) - } else if (uiState.currentStep is SwapStep.ActionRequired) { - uiState.currentStep.action.getDescription()?.let { actionDescription -> - VSpacer(height = 12.dp) - TextImportantWarning( - modifier = Modifier.padding(horizontal = 16.dp), - text = actionDescription - ) - } - } +@Composable +private fun SwapWarnings(uiState: SwapUiState) { + Column { + uiState.warningMessage?.let { + VSpacer(height = 12.dp) + TextImportantWarning( + modifier = Modifier.padding(horizontal = 16.dp), + text = it.getString(), + icon = R.drawable.ic_attention_20, + ) + } + if (uiState.quoteCautions.isNotEmpty()) { + Cautions(uiState.quoteCautions.map { it.toCautionViewItem() }) + } + if (uiState.direction == SwapAmountDirection.Out && + uiState.amountOutAccuracy == SwapAmountAccuracy.Estimated + ) { + VSpacer(height = 12.dp) + TextImportantWarning( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.swap_estimated_amount_warning), + icon = R.drawable.ic_attention_20, + ) + } + SwapStepWarning(uiState) + } +} - VSpacer(height = 32.dp) - } +@Composable +private fun SwapStepWarning(uiState: SwapUiState) { + val action = (uiState.currentStep as? SwapStep.ActionRequired)?.action + if (uiState.error is PriceImpactTooHigh) { + VSpacer(height = 12.dp) + TextImportantError( + modifier = Modifier.padding(horizontal = 16.dp), + icon = R.drawable.ic_attention_20, + title = stringResource(R.string.Swap_PriceImpact), + text = stringResource(R.string.Swap_PriceImpactTooHigh, uiState.error.providerTitle.orEmpty()), + ) + } else { + action?.getDescription()?.let { + VSpacer(height = 12.dp) + TextImportantWarning(modifier = Modifier.padding(horizontal = 16.dp), text = it) + } + } +} +@Composable +private fun SwapSuggestions( + uiState: SwapUiState, + controller: SwapScreenController, + inputFocus: SwapInputFocus, + keyboardOpen: Boolean, + modifier: Modifier = Modifier, +) { + if (inputFocus == SwapInputFocus.None || !keyboardOpen) return + val focusManager = LocalFocusManager.current + val hasBalance = uiState.availableBalance?.signum() == 1 + val percentagesEnabled = inputFocus == SwapInputFocus.AmountIn + SuggestionsBar( + modifier = modifier.imePadding(), + onDelete = { inputFocus.clear(controller) }, + onSelect = { + focusManager.clearFocus() + controller.enterAmountPercentage(it) + }, + percents = if (percentagesEnabled) listOf(25, 50, 75, 100) else emptyList(), + selectEnabled = hasBalance && percentagesEnabled, + deleteEnabled = inputFocus.hasValue(uiState), + ) +} - if (amountInputHasFocus && keyboardState == Keyboard.Opened) { - val hasNonZeroBalance = - uiState.availableBalance != null && uiState.availableBalance > BigDecimal.ZERO - - SuggestionsBar( - modifier = Modifier - .imePadding() - .align(Alignment.BottomCenter), - onDelete = { - onEnterAmount.invoke(null) - }, - onSelect = { - focusManager.clearFocus() - onEnterAmountPercentage(it) - }, - selectEnabled = hasNonZeroBalance, - deleteEnabled = uiState.amountIn != null, - ) - } - } +private fun SwapInputFocus.clear(controller: SwapScreenController) { + when (this) { + SwapInputFocus.None -> Unit + SwapInputFocus.AmountIn -> controller.enterAmount(null) + SwapInputFocus.FiatAmountIn -> controller.enterFiatAmount(null) + SwapInputFocus.AmountOut -> controller.enterAmountOut(null) + SwapInputFocus.FiatAmountOut -> controller.enterFiatAmountOut(null) } } +private fun SwapInputFocus.hasValue(uiState: SwapUiState): Boolean = when (this) { + SwapInputFocus.None -> false + SwapInputFocus.AmountIn -> uiState.amountIn != null + SwapInputFocus.FiatAmountIn -> uiState.fiatAmountIn != null + SwapInputFocus.AmountOut -> uiState.displayAmountOut != null + SwapInputFocus.FiatAmountOut -> uiState.fiatAmountOut != null +} + @Composable fun PriceImpactField( priceImpact: BigDecimal?, @@ -938,23 +1029,9 @@ fun PriceField(tokenIn: Token, tokenOut: Token, amountIn: BigDecimal, amountOut: @Composable private fun SwapInput( - amountIn: BigDecimal?, - fiatAmountIn: BigDecimal?, - fiatAmountInputEnabled: Boolean, - onSwitchPairs: () -> Unit, - amountOut: BigDecimal?, - fiatAmountOut: BigDecimal?, - fiatPriceImpact: BigDecimal?, - fiatPriceImpactLevel: PriceImpactLevel?, - onValueChange: (BigDecimal?) -> Unit, - onFiatValueChange: (BigDecimal?) -> Unit, - onClickCoinFrom: () -> Unit, - onClickCoinTo: () -> Unit, - tokenIn: Token?, - tokenOut: Token?, - currency: Currency, - intermediateToken: Token? = null, - onFocusChanged: (FocusState) -> Unit, + uiState: SwapUiState, + controller: SwapScreenController, + onFocusChange: (SwapInputFocus, FocusState) -> Unit, ) { Box( modifier = Modifier.padding(horizontal = 16.dp) @@ -966,84 +1043,70 @@ private fun SwapInput( .background(ComposeAppTheme.colors.lawrence) ) { SwapCoinInputIn( - coinAmount = amountIn, - fiatAmount = fiatAmountIn, - currency = currency, - onValueChange = onValueChange, - onFiatValueChange = onFiatValueChange, - fiatAmountInputEnabled = fiatAmountInputEnabled, - token = tokenIn, - onClickCoin = onClickCoinFrom, - onFocusChanged = onFocusChanged + uiState = uiState, + controller = controller, + onFocusChange = onFocusChange, ) SwapCoinInputTo( - coinAmount = amountOut, - fiatAmount = fiatAmountOut, - fiatPriceImpact = fiatPriceImpact, - fiatPriceImpactLevel = fiatPriceImpactLevel, - currency = currency, - token = tokenOut, - onClickCoin = onClickCoinTo + state = SwapOutputInputState.from(uiState), + onAmountChange = controller::enterAmountOut, + onFiatAmountChange = controller::enterFiatAmountOut, + onSelectCoin = controller::selectCoinTo, + onFocusChange = onFocusChange, ) } - Divider( + HorizontalDivider( modifier = Modifier.align(Alignment.Center), thickness = 1.dp, color = ComposeAppTheme.colors.steel10 ) SwapDirectionIndicator( modifier = Modifier.align(Alignment.Center), - intermediateToken = intermediateToken, - onClick = onSwitchPairs + intermediateToken = uiState.multiSwapRoute?.intermediateCoin, + onClick = controller::switchPairs ) } } @Composable private fun SwapCoinInputIn( - coinAmount: BigDecimal?, - fiatAmount: BigDecimal?, - currency: Currency, - onValueChange: (BigDecimal?) -> Unit, - onFiatValueChange: (BigDecimal?) -> Unit, - fiatAmountInputEnabled: Boolean, - token: Token?, - onClickCoin: () -> Unit, - onFocusChanged: (FocusState) -> Unit, + uiState: SwapUiState, + controller: SwapScreenController, + onFocusChange: (SwapInputFocus, FocusState) -> Unit, ) { Row( modifier = Modifier - .onFocusChanged(onFocusChanged) .padding(horizontal = 16.dp, vertical = 20.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { AmountInput( - value = coinAmount, - onValueChange = onValueChange + value = uiState.amountIn, + accessibilityLabel = stringResource(R.string.Swap_YouPay), + onValueChange = controller::enterAmount, + onFocusChange = { onFocusChange(SwapInputFocus.AmountIn, it) }, ) VSpacer(height = 3.dp) FiatAmountInput( - value = fiatAmount, - currency = currency, - onValueChange = onFiatValueChange, - enabled = fiatAmountInputEnabled + value = uiState.fiatAmountIn, + currency = uiState.currency, + onValueChange = controller::enterFiatAmount, + enabled = uiState.fiatAmountInInputEnabled, + onFocusChange = { onFocusChange(SwapInputFocus.FiatAmountIn, it) }, ) } HSpacer(width = 8.dp) - CoinSelector(token, onClickCoin) + CoinSelector(uiState.tokenIn, controller::selectCoinFrom) } } @Composable private fun SwapCoinInputTo( - coinAmount: BigDecimal?, - fiatAmount: BigDecimal?, - fiatPriceImpact: BigDecimal?, - fiatPriceImpactLevel: PriceImpactLevel?, - currency: Currency, - token: Token?, - onClickCoin: () -> Unit, + state: SwapOutputInputState, + onAmountChange: (BigDecimal?) -> Unit, + onFiatAmountChange: (BigDecimal?) -> Unit, + onSelectCoin: () -> Unit, + onFocusChange: (SwapInputFocus, FocusState) -> Unit, ) { Row( modifier = Modifier @@ -1051,39 +1114,117 @@ private fun SwapCoinInputTo( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - if (coinAmount == null) { - headline1_grey(text = "0") - } else { - headline1_leah( - text = coinAmount.toPlainString(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + AmountInput( + value = state.amount, + accessibilityLabel = stringResource(R.string.Swap_YouGet), + onValueChange = onAmountChange, + onFocusChange = { onFocusChange(SwapInputFocus.AmountOut, it) }, + ) VSpacer(height = 3.dp) - if (fiatAmount == null) { - body_grey(text = "${currency.symbol}0") - } else { - Row { - body_grey(text = "${currency.symbol}${fiatAmount.toPlainString()}") - fiatPriceImpact?.let { diff -> - HSpacer(width = 4.dp) - Text( - text = stringResource( - R.string.Swap_FiatPriceImpact, - diff.toPlainString() - ), - style = ComposeAppTheme.typography.body, - color = getPriceImpactColor(fiatPriceImpactLevel), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Row { + FiatAmountInput( + value = state.fiatAmount, + currency = state.currency, + onValueChange = onFiatAmountChange, + enabled = state.fiatAmountInputEnabled, + onFocusChange = { onFocusChange(SwapInputFocus.FiatAmountOut, it) }, + modifier = Modifier.weight(1f, fill = false), + fillWidth = state.fiatPriceImpact == null, + ) + state.fiatPriceImpact?.let { diff -> + HSpacer(width = 4.dp) + Text( + text = stringResource( + R.string.Swap_FiatPriceImpact, + diff.toPlainString() + ), + style = ComposeAppTheme.typography.body, + color = getPriceImpactColor(state.fiatPriceImpactLevel), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } HSpacer(width = 8.dp) - CoinSelector(token, onClickCoin) + CoinSelector(state.token, onSelectCoin) + } +} + +private data class SwapOutputInputState( + val amount: BigDecimal?, + val fiatAmount: BigDecimal?, + val fiatPriceImpact: BigDecimal?, + val currency: Currency, + val token: Token?, + val fiatAmountInputEnabled: Boolean, + val fiatPriceImpactLevel: PriceImpactLevel?, +) { + companion object { + fun from(uiState: SwapUiState) = SwapOutputInputState( + amount = uiState.displayAmountOut, + fiatAmount = uiState.fiatAmountOut, + fiatPriceImpact = uiState.fiatPriceImpact, + currency = uiState.currency, + token = uiState.tokenOut, + fiatAmountInputEnabled = uiState.fiatAmountOutInputEnabled, + fiatPriceImpactLevel = uiState.fiatPriceImpactLevel, + ) + } +} + +@Preview(name = "Exact in", showBackground = true) +@Composable +internal fun SwapOutputInputExactInPreview() { + SwapOutputInputPreview( + amount = BigDecimal("0.12345678"), + fiatAmount = BigDecimal("20"), + fiatPriceImpact = BigDecimal("-1.62"), + ) +} + +@Preview(name = "Exact out", showBackground = true) +@Composable +internal fun SwapOutputInputExactOutPreview() { + SwapOutputInputPreview( + amount = BigDecimal("100"), + fiatAmount = BigDecimal("123456789.12"), + fiatPriceImpact = BigDecimal("1.23"), + ) +} + +@Preview(name = "No price impact", showBackground = true) +@Composable +internal fun SwapOutputInputNoPriceImpactPreview() { + SwapOutputInputPreview( + amount = BigDecimal("100"), + fiatAmount = null, + fiatPriceImpact = null, + ) +} + +@Composable +private fun SwapOutputInputPreview( + amount: BigDecimal, + fiatAmount: BigDecimal?, + fiatPriceImpact: BigDecimal?, +) { + ComposeAppTheme { + SwapCoinInputTo( + state = SwapOutputInputState( + amount = amount, + fiatAmount = fiatAmount, + fiatPriceImpact = fiatPriceImpact, + currency = Currency("usd", "$", 6, 0), + token = null, + fiatAmountInputEnabled = true, + fiatPriceImpactLevel = PriceImpactLevel.Normal, + ), + onAmountChange = {}, + onFiatAmountChange = {}, + onSelectCoin = {}, + onFocusChange = { _, _ -> }, + ) } } @@ -1122,33 +1263,44 @@ private fun FiatAmountInput( currency: Currency, onValueChange: (BigDecimal?) -> Unit, enabled: Boolean, + onFocusChange: (FocusState) -> Unit, + modifier: Modifier = Modifier, + fillWidth: Boolean = true, ) { var text by remember(value) { mutableStateOf(value?.toPlainString() ?: "") } - Row { + val textStyle = ColoredTextStyle( + color = ComposeAppTheme.colors.grey, + textStyle = ComposeAppTheme.typography.body + ) + val inputModifier = if (fillWidth) { + Modifier.fillMaxWidth() + } else { + val textWidth = with(LocalDensity.current) { + rememberTextMeasurer() + .measure(text.ifEmpty { "0" }, textStyle, maxLines = 1) + .size.width + .toDp() + } + Modifier.width(textWidth) + } + Row(modifier = modifier) { body_grey(text = currency.symbol) BasicTextField( - modifier = Modifier.fillMaxWidth(), + modifier = inputModifier.onFocusChanged(onFocusChange), value = text, - onValueChange = { - try { - val amount = if (it.isBlank()) { - null - } else { - it.toBigDecimalOrNullExt() - } - text = it - onValueChange.invoke(amount) - } catch (e: Exception) { - + onValueChange = onTextChange@{ updatedText -> + val amount = if (updatedText.isBlank()) { + null + } else { + parseAmount(updatedText) ?: return@onTextChange } + text = updatedText + onValueChange(amount) }, enabled = enabled, - textStyle = ColoredTextStyle( - color = ComposeAppTheme.colors.grey, - textStyle = ComposeAppTheme.typography.body - ), + textStyle = textStyle, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal @@ -1193,59 +1345,49 @@ private fun Selector( @Composable private fun AmountInput( value: BigDecimal?, + accessibilityLabel: String, onValueChange: (BigDecimal?) -> Unit, + onFocusChange: (FocusState) -> Unit, ) { - var amount by rememberSaveable { - mutableStateOf(value) - } - + var amount by rememberSaveable { mutableStateOf(value) } var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(text = amount?.toPlainString() ?: "")) } - LaunchedEffect(value) { if (value?.stripTrailingZeros() != amount?.stripTrailingZeros()) { amount = value - textFieldValue = TextFieldValue(text = amount?.toPlainString() ?: "") } } - - var setCursorToEndOnFocused by remember { - mutableStateOf(false) - } - + var setCursorToEndOnFocused by remember { mutableStateOf(false) } BasicTextField( modifier = Modifier .fillMaxWidth() + .semantics { contentDescription = accessibilityLabel } .onFocusChanged { + onFocusChange(it) setCursorToEndOnFocused = it.isFocused - if (!it.isFocused) { textFieldValue = textFieldValue.copy(selection = TextRange.Zero) } }, value = textFieldValue, onValueChange = { newValue -> - try { - val text = newValue.text - amount = if (text.isBlank()) { - null - } else { - text.toBigDecimalOrNullExt() - } - - if (!setCursorToEndOnFocused) { - textFieldValue = newValue - } else { - textFieldValue = newValue.copy(selection = TextRange(text.length)) - setCursorToEndOnFocused = false - } - - onValueChange.invoke(amount) - } catch (e: Exception) { - + val text = newValue.text + val parsedAmount = parseAmount(text) + val negative = parsedAmount?.let { it < BigDecimal.ZERO } == true + amount = parsedAmount?.takeUnless { negative } + + if (negative) { + textFieldValue = TextFieldValue() + } else if (!setCursorToEndOnFocused) { + textFieldValue = newValue + } else { + textFieldValue = newValue.copy(selection = TextRange(text.length)) + setCursorToEndOnFocused = false } + + onValueChange(amount) }, textStyle = ColoredTextStyle( color = ComposeAppTheme.colors.leah, @@ -1265,6 +1407,9 @@ private fun AmountInput( ) } +private fun parseAmount(value: String): BigDecimal? = + value.takeUnless(String::isBlank)?.let { tryOrNull { it.toBigDecimalOrNullExt() } } + @Composable private fun MultiSwapLegCard( legIndex: Int, @@ -1334,39 +1479,3 @@ fun getPriceImpactColor(priceImpactLevel: PriceImpactLevel?): Color { else -> ComposeAppTheme.colors.grey } } - -@Preview(showBackground = true) -@Composable -private fun SwapCoinInputToPreview() { - ComposeAppTheme { - Column { - SwapCoinInputTo( - coinAmount = BigDecimal("0.12345678"), - fiatAmount = BigDecimal("1234.56"), - fiatPriceImpact = BigDecimal("1.23"), - fiatPriceImpactLevel = PriceImpactLevel.Normal, - currency = Currency("usd", "$", 6, 0), - token = null, - onClickCoin = {} - ) - SwapCoinInputTo( - coinAmount = BigDecimal("0.12345678"), - fiatAmount = BigDecimal("1234.56"), - fiatPriceImpact = BigDecimal("1.23"), - fiatPriceImpactLevel = PriceImpactLevel.Good, - currency = Currency("usd", "$", 6, 0), - token = null, - onClickCoin = {} - ) - SwapCoinInputTo( - coinAmount = BigDecimal("0.12345678"), - fiatAmount = BigDecimal("1234.56"), - fiatPriceImpact = BigDecimal("1.23"), - fiatPriceImpactLevel = PriceImpactLevel.Warning, - currency = Currency("usd", "$", 6, 0), - token = null, - onClickCoin = {} - ) - } - } -} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapProviderQuote.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapProviderQuote.kt index b7a3aa0de35..3208bed40e5 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapProviderQuote.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapProviderQuote.kt @@ -4,19 +4,26 @@ import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider data class SwapProviderQuote( val provider: IMultiSwapProvider, - val swapQuote: ISwapQuote + val swapQuote: ISwapQuote, + val executionMode: SwapExecutionMode = SwapExecutionMode.ExactIn, + val amountOutAccuracy: SwapAmountAccuracy = SwapAmountAccuracy.Exact, + val createdAt: Long = System.currentTimeMillis(), ) { val tokenIn by swapQuote::tokenIn val tokenOut by swapQuote::tokenOut val amountIn by swapQuote::amountIn val amountOut by swapQuote::amountOut + val amountInMax by swapQuote::amountInMax val fields by swapQuote::fields val priceImpact by swapQuote::priceImpact val actionRequired by swapQuote::actionRequired + val cautions by swapQuote::cautions val estimationTime by swapQuote::estimationTime - - val createdAt = System.currentTimeMillis() } -fun Iterable.sortedByBestAmountOut(): List = - sortedByDescending(SwapProviderQuote::amountOut) +fun Iterable.sortedByBest( + direction: SwapAmountDirection, +): List = when (direction) { + SwapAmountDirection.In -> sortedByDescending(SwapProviderQuote::amountOut) + SwapAmountDirection.Out -> sortedBy(SwapProviderQuote::amountIn) +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapQuoteService.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapQuoteService.kt index 9701372274a..a1fecb1b47c 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapQuoteService.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapQuoteService.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -31,7 +32,8 @@ class SwapQuoteService( private val dispatcherProvider: DispatcherProvider, ) { private companion object { - const val DEBOUNCE_INPUT_MSEC: Long = 300 + const val DEBOUNCE_INPUT_IN_MSEC = 300L + const val DEBOUNCE_INPUT_OUT_MSEC = 600L } private var runQuotationJob: Job? = null @@ -50,7 +52,8 @@ class SwapQuoteService( fun findProviderById(id: String): IMultiSwapProvider? = swapProvidersRegistry.findById(id) - private var amountIn: BigDecimal? = null + private var amount: BigDecimal? = null + private var direction = SwapAmountDirection.In private var tokenIn: Token? = null private var tokenOut: Token? = null private var quoting = false @@ -62,7 +65,7 @@ class SwapQuoteService( private val _stateFlow = MutableStateFlow( State( - amountIn = amountIn, + amountIn = null, tokenIn = tokenIn, tokenOut = tokenOut, quoting = quoting, @@ -71,6 +74,9 @@ class SwapQuoteService( quote = quote, error = error, multiSwapRoute = multiSwapRoute, + direction = direction, + requestedAmountOut = null, + amountInMax = null, ) ) val stateFlow = _stateFlow.asStateFlow() @@ -78,6 +84,9 @@ class SwapQuoteService( private val coroutineScope = CoroutineScope(dispatcherProvider.io + SupervisorJob()) private var quotingJob: Job? = null private var settings: Map = mapOf() + val swapSettings: Map + get() = settings + private var previousDisabledIds = swapProvidersRepository.disabledIds.value fun clear() { coroutineScope.cancel() @@ -87,13 +96,24 @@ class SwapQuoteService( coroutineScope.launch { swapProvidersRepository.disabledIds .drop(1) - .collect { - onDisabledProvidersChanged() + .collect { disabledIds -> + onDisabledProvidersChanged(disabledIds) } } } - private fun onDisabledProvidersChanged() { + private fun onDisabledProvidersChanged(disabledIds: Set) { + val newlyEnabledIds = previousDisabledIds - disabledIds + previousDisabledIds = disabledIds + if (direction == SwapAmountDirection.Out && + newlyEnabledIds.any { id -> quotes.none { it.provider.id == id } } + ) { + preferredProvider = quote?.provider + runQuotationJob?.cancel() + runQuotation() + return + } + val enabledQuotes = quotes.filterNot { swapProvidersRepository.isDisabled(it.provider.id) } @@ -108,6 +128,12 @@ class SwapQuoteService( multiSwapRoute = null emitState() } + direction == SwapAmountDirection.Out -> { + quote = null + error = if (quoting) null else NoExactOutSwapProvider() + multiSwapRoute = null + emitState() + } else -> runQuotation() } } @@ -115,7 +141,10 @@ class SwapQuoteService( private fun emitState() { _stateFlow.update { State( - amountIn = amountIn, + amountIn = when (direction) { + SwapAmountDirection.In -> amount + SwapAmountDirection.Out -> quote?.amountIn + }, tokenIn = tokenIn, tokenOut = tokenOut, quoting = quoting, @@ -124,6 +153,9 @@ class SwapQuoteService( quote = quote, error = error, multiSwapRoute = multiSwapRoute, + direction = direction, + requestedAmountOut = amount.takeIf { direction == SwapAmountDirection.Out }, + amountInMax = quote?.amountInMax, ) } } @@ -154,19 +186,25 @@ class SwapQuoteService( val tokenIn = tokenIn val tokenOut = tokenOut - val amountIn = amountIn + val amount = amount + val direction = direction if (tokenIn != null && tokenOut != null) { quotingJob = coroutineScope.launch { - if (amountIn != null && amountIn > BigDecimal.ZERO) { + if (amount != null && amount > BigDecimal.ZERO) { quoting = true emitState() val newQuotes = fetchSwapQuotesUseCase( - providers = allProviders, + providers = if (direction == SwapAmountDirection.Out) { + enabledProviders + } else { + allProviders + }, tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, + amount = amount, + direction = direction, settings = settings, onProviderError = { _, e -> when (e) { @@ -185,7 +223,7 @@ class SwapQuoteService( } }, ) - if (amountIn != this@SwapQuoteService.amountIn) { + if (amount != this@SwapQuoteService.amount || direction != this@SwapQuoteService.direction) { return@launch // ignore outdated quotes } quotes = newQuotes @@ -199,8 +237,19 @@ class SwapQuoteService( } if (enabledQuotes.isEmpty()) { - tryFallbackToMultiSwapRoute(tokenIn, tokenOut, amountIn, noDirectProviders = enabledQuotes.isEmpty()) - quote = multiSwapRoute?.leg1Quotes?.firstOrNull() + if (direction == SwapAmountDirection.In) { + tryFallbackToMultiSwapRoute( + tokenIn, + tokenOut, + amount, + noDirectProviders = true, + ) + quote = multiSwapRoute?.leg1Quotes?.firstOrNull() + } else { + multiSwapRoute = null + quote = null + error = NoExactOutSwapProvider() + } } else { error = null multiSwapRoute = null @@ -240,13 +289,12 @@ class SwapQuoteService( error = null } else { multiSwapRoute = null - error = preservedAmountError() ?: resolveEmptyResultError(tokenIn, tokenOut, noDirectProviders) + error = error + ?.takeIf { it is SwapDepositTooSmall || it is SwapAmountOutOfRange } + ?: resolveEmptyResultError(tokenIn, tokenOut, noDirectProviders) } } - private fun preservedAmountError(): Throwable? = - error?.takeIf { it is SwapDepositTooSmall || it is SwapAmountOutOfRange } - private suspend fun resolveEmptyResultError( tokenIn: Token, tokenOut: Token, @@ -260,14 +308,22 @@ class SwapQuoteService( else -> NoSupportedSwapProvider() } + fun setAmountIn(value: BigDecimal?) { + setAmount(value, SwapAmountDirection.In) + } - fun setAmount(v: BigDecimal?) { - if (amountIn == v) { + fun setAmountOut(value: BigDecimal?) { + setAmount(value, SwapAmountDirection.Out) + } + + private fun setAmount(value: BigDecimal?, newDirection: SwapAmountDirection) { + if (amount == value && direction == newDirection) { runQuotationWithDebounce() return } - amountIn = v + amount = value + direction = newDirection preferredProvider = null runQuotationWithDebounce() @@ -285,7 +341,12 @@ class SwapQuoteService( emitState() runQuotationJob = coroutineScope.launch { - delay(DEBOUNCE_INPUT_MSEC) + delay( + when (direction) { + SwapAmountDirection.In -> DEBOUNCE_INPUT_IN_MSEC + SwapAmountDirection.Out -> DEBOUNCE_INPUT_OUT_MSEC + } + ) runQuotation() } } @@ -320,7 +381,11 @@ class SwapQuoteService( tokenIn = tokenOut tokenOut = tmpTokenIn - amountIn = multiSwapRoute?.selectedLeg2Quote?.amountOut ?: quote?.amountOut + amount = when (direction) { + SwapAmountDirection.In -> multiSwapRoute?.selectedLeg2Quote?.amountOut ?: quote?.amountOut + SwapAmountDirection.Out -> amount + } + direction = SwapAmountDirection.In runQuotation(clearQuotes = true) } @@ -336,6 +401,15 @@ class SwapQuoteService( runQuotation() } + fun invalidateAndReQuote() { + runQuotationJob?.cancel() + coroutineScope.launch { + quotingJob?.cancelAndJoin() + fetchSwapQuotesUseCase.invalidateSearchCache() + reQuote() + } + } + fun setSwapSettings(settings: Map) { this.settings = settings @@ -347,11 +421,9 @@ class SwapQuoteService( } fun onActionCompleted() { - reQuote() + invalidateAndReQuote() } - fun getSwapSettings() = settings - data class State( val amountIn: BigDecimal?, val tokenIn: Token?, @@ -362,5 +434,8 @@ class SwapQuoteService( val quote: SwapProviderQuote?, val error: Throwable?, val multiSwapRoute: MultiSwapRoute?, + val direction: SwapAmountDirection, + val requestedAmountOut: BigDecimal?, + val amountInMax: BigDecimal?, ) } diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapRouteNotFound.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapRouteNotFound.kt index 935b0b9fb48..a9ce023aa16 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapRouteNotFound.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapRouteNotFound.kt @@ -5,5 +5,6 @@ import java.math.BigDecimal class SwapRouteNotFound : Throwable() class NoSupportedSwapProvider : Throwable() class NoEnabledSwapProvider : Throwable() +class NoExactOutSwapProvider : Throwable() class SwapDepositTooSmall(val minValue: BigDecimal) : Throwable() class SwapAmountOutOfRange : Throwable() diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderScreen.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderScreen.kt index 806f720bb86..5b6618e3d48 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderScreen.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderScreen.kt @@ -18,9 +18,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -58,6 +58,7 @@ import cash.p.terminal.ui_compose.components.MenuItem import cash.p.terminal.ui_compose.components.RowUniversal import cash.p.terminal.ui_compose.components.VSpacer import cash.p.terminal.ui_compose.components.body_leah +import cash.p.terminal.ui_compose.components.micro_grey import cash.p.terminal.ui_compose.components.subhead2_grey import cash.p.terminal.ui_compose.components.subhead2_leah import cash.p.terminal.ui_compose.theme.ComposeAppTheme @@ -247,6 +248,7 @@ private fun ProviderItem( } Row(verticalAlignment = Alignment.CenterVertically) { ProviderRiskBadge(riskType = provider.riskType) + AccuracyBadge(viewItem.quote.amountOutAccuracy) HFillSpacer(minWidth = 8.dp) viewItem.fiatAmount?.let { fiatAmount -> subhead2_grey(text = fiatAmount, textAlign = TextAlign.End) @@ -286,6 +288,17 @@ private fun ProviderItem( } } +@Composable +private fun AccuracyBadge(accuracy: SwapAmountAccuracy) { + val symbol = when (accuracy) { + SwapAmountAccuracy.Exact -> return + SwapAmountAccuracy.AtLeast -> "≥" + SwapAmountAccuracy.Estimated -> "≈" + } + HSpacer(width = 6.dp) + micro_grey(text = symbol) +} + @Composable private fun ExchangeBlock( from: String, diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt index 1046e4a940a..d242fa702d5 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt @@ -18,12 +18,13 @@ import java.math.RoundingMode class SwapSelectProviderViewModel( private val quotes: List, + private val direction: SwapAmountDirection, private val assetFiatRateService: AssetFiatRateService = getKoinInstance() ) : ViewModelUiState() { private val currencyManager = App.currencyManager private val currency = currencyManager.baseCurrency - private var token: Token = quotes.first().tokenOut + private var token: Token = displayToken(quotes.first()) private var rate: BigDecimal? = null // To show straight or reversed rate in provider list item @@ -49,25 +50,26 @@ class SwapSelectProviderViewModel( private fun List.sorted(): List = when (sortType) { ProviderSortType.BestPrice -> sortedWith( - compareByDescending { it.amountOut } - .thenBy { it.estimationTime ?: Long.MAX_VALUE } + priceComparator().thenBy { it.estimationTime ?: Long.MAX_VALUE } ) ProviderSortType.BestTime -> sortedWith( compareBy { it.estimationTime ?: Long.MAX_VALUE } - .thenByDescending { it.amountOut } + .then(priceComparator()) ) } private fun getViewItems(quotes: List): List { // Diff is always measured against the best rate, regardless of the active sort order. - val bestProviderAmountOut = quotes.maxOfOrNull { it.amountOut } ?: return emptyList() + val bestAmount = quotes.minOfOrNull(::priceRank) ?: return emptyList() return quotes.map { quote -> - val fiatAmount = getFiatValue(quote.amountOut)?.getFormattedFull() + val amount = displayAmount(quote) + val token = displayToken(quote) + val fiatAmount = getFiatValue(amount)?.getFormattedFull() val tokenAmount = App.numberFormatter.formatCoinFull( - value = quote.amountOut, - code = quote.tokenOut.coin.code, - coinDecimals = quote.tokenOut.decimals + value = amount, + code = token.coin.code, + coinDecimals = token.decimals ) val (rateFrom, rateTo) = getRateString( tokenIn = quote.tokenIn, @@ -79,16 +81,7 @@ class SwapSelectProviderViewModel( quote = quote, fiatAmount = fiatAmount, tokenAmount = tokenAmount, - diffWithFirst = if (quote.amountOut < bestProviderAmountOut) { - tryOrNull { - ((quote.amountOut - bestProviderAmountOut) / bestProviderAmountOut * BigDecimal( - 100 - )).setScale(2, RoundingMode.DOWN) - .stripTrailingZeros() - } - } else { - null - }, + diffWithFirst = priceDifference(amount, bestAmount), rateFrom = rateFrom, rateTo = rateTo, estimationTime = quote.estimationTime @@ -96,6 +89,48 @@ class SwapSelectProviderViewModel( } } + private fun priceComparator(): Comparator = when (direction) { + SwapAmountDirection.In -> compareByDescending(SwapProviderQuote::amountOut) + SwapAmountDirection.Out -> compareBy(SwapProviderQuote::amountIn) + } + + private fun priceRank(quote: SwapProviderQuote): BigDecimal = when (direction) { + SwapAmountDirection.In -> quote.amountOut.negate() + SwapAmountDirection.Out -> quote.amountIn + } + + private fun displayAmount(quote: SwapProviderQuote): BigDecimal = when (direction) { + SwapAmountDirection.In -> quote.amountOut + SwapAmountDirection.Out -> quote.amountIn + } + + private fun displayToken(quote: SwapProviderQuote): Token = when (direction) { + SwapAmountDirection.In -> quote.tokenOut + SwapAmountDirection.Out -> quote.tokenIn + } + + private fun priceDifference(amount: BigDecimal, bestRank: BigDecimal): BigDecimal? { + val bestAmount = when (direction) { + SwapAmountDirection.In -> bestRank.negate() + SwapAmountDirection.Out -> bestRank + } + val isWorse = when (direction) { + SwapAmountDirection.In -> amount < bestAmount + SwapAmountDirection.Out -> amount > bestAmount + } + if (!isWorse || bestAmount.compareTo(BigDecimal.ZERO) == 0) return null + + return tryOrNull { + val difference = when (direction) { + SwapAmountDirection.In -> amount - bestAmount + SwapAmountDirection.Out -> bestAmount - amount + } + difference.multiply(BigDecimal(100)) + .divide(bestAmount, 2, RoundingMode.DOWN) + .stripTrailingZeros() + } + } + override fun createState() = SwapSelectProviderUiState( quoteViewItems = quoteViewItems, sortType = sortType @@ -146,10 +181,13 @@ class SwapSelectProviderViewModel( } } - class Factory(private val quotes: List) : ViewModelProvider.Factory { + class Factory( + private val quotes: List, + private val direction: SwapAmountDirection, + ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return SwapSelectProviderViewModel(quotes) as T + return SwapSelectProviderViewModel(quotes, direction) as T } } } diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt index dee9d7f9633..d9216cd1cee 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt @@ -51,7 +51,9 @@ class SwapViewModel( private var fiatAmountIn: BigDecimal? = null private var fiatAmountOut: BigDecimal? = null - private var fiatAmountInputEnabled = false + private var fiatAmountInInputEnabled = false + private var fiatAmountOutInputEnabled = false + private var fiatInputDirection: SwapAmountDirection? = null private val currency = currencyManager.baseCurrency private val balanceHiddenManager: IBalanceHiddenManager by inject(IBalanceHiddenManager::class.java) private val walletUseCase: WalletUseCase by inject(WalletUseCase::class.java) @@ -91,21 +93,12 @@ class SwapViewModel( } viewModelScope.launch { fiatServiceIn.stateFlow.collect { - fiatAmountInputEnabled = it.rate != null - fiatAmountIn = it.fiatAmount - quoteService.setAmount(it.amount) - priceImpactService.setFiatAmountIn(fiatAmountIn) - - emitState() + handleUpdatedFiatState(it, SwapAmountDirection.In) } } viewModelScope.launch { fiatServiceOut.stateFlow.collect { - fiatAmountOut = it.fiatAmount - - priceImpactService.setFiatAmountOut(fiatAmountOut) - - emitState() + handleUpdatedFiatState(it, SwapAmountDirection.Out) } } viewModelScope.launch { @@ -147,6 +140,7 @@ class SwapViewModel( return SwapUiState( amountIn = quoteState.amountIn, + displayAmountOut = displayAmountOut, tokenIn = quoteState.tokenIn, tokenOut = quoteState.tokenOut, quoting = quoteState.quoting, @@ -173,10 +167,16 @@ class SwapViewModel( fiatAmountOut = fiatAmountOut, fiatPriceImpact = priceImpactState.fiatPriceImpact, currency = currency, - fiatAmountInputEnabled = fiatAmountInputEnabled, + fiatAmountInInputEnabled = fiatAmountInInputEnabled, + fiatAmountOutInputEnabled = fiatAmountOutInputEnabled, fiatPriceImpactLevel = priceImpactState.fiatPriceImpactLevel, timeout = timerState.timeout, multiSwapRoute = quoteState.multiSwapRoute, + direction = quoteState.direction, + requestedAmountOut = quoteState.requestedAmountOut, + amountInMax = quoteState.amountInMax, + amountOutAccuracy = quoteState.quote?.amountOutAccuracy ?: SwapAmountAccuracy.Exact, + quoteCautions = quoteState.quote?.cautions.orEmpty(), ) } @@ -207,7 +207,7 @@ class SwapViewModel( this.quoteState = quoteState balanceService.setToken(quoteState.tokenIn) - balanceService.setAmount(quoteState.amountIn) + balanceService.setAmount(quoteState.amountInMax ?: quoteState.amountIn) priceImpactService.setPriceImpact( quoteState.quote?.priceImpact?.negate(), @@ -217,9 +217,7 @@ class SwapViewModel( fiatServiceIn.setToken(quoteState.tokenIn) fiatServiceIn.setAmount(quoteState.amountIn) fiatServiceOut.setToken(quoteState.tokenOut) - val finalAmountOut = quoteState.multiSwapRoute?.selectedLeg2Quote?.amountOut - ?: quoteState.quote?.amountOut - fiatServiceOut.setAmount(finalAmountOut) + fiatServiceOut.setAmount(displayAmountOut) emitState() // Emit immediately so UI updates without waiting for warning fetchWarningMessageAsync() @@ -239,11 +237,39 @@ class SwapViewModel( emitState() } + private fun handleUpdatedFiatState( + state: FiatService.State, + direction: SwapAmountDirection, + ) { + when (direction) { + SwapAmountDirection.In -> { + fiatAmountInInputEnabled = state.rate != null + fiatAmountIn = state.fiatAmount + priceImpactService.setFiatAmountIn(state.fiatAmount) + } + SwapAmountDirection.Out -> { + fiatAmountOutInputEnabled = state.rate != null + fiatAmountOut = state.fiatAmount + priceImpactService.setFiatAmountOut(state.fiatAmount) + } + } + if (state.inputSource == FiatService.InputSource.Fiat && + fiatInputDirection == direction + ) { + setQuoteAmount(state.amount, direction) + } + emitState() + } + fun onSelectQuote(quote: SwapProviderQuote) { quoteService.selectQuote(quote) } - fun onEnterAmount(v: BigDecimal?) = quoteService.setAmount(v) + fun onEnterAmount(v: BigDecimal?) = setTokenAmount(v, SwapAmountDirection.In) + fun onEnterAmountOut(v: BigDecimal?) = setTokenAmount(v, SwapAmountDirection.Out) + fun onEnterFiatAmount(v: BigDecimal?) = setFiatAmount(v, SwapAmountDirection.In) + fun onEnterFiatAmountOut(v: BigDecimal?) = setFiatAmount(v, SwapAmountDirection.Out) + fun onEnterAmountPercentage(percentage: Int) { val tokenIn = quoteState.tokenIn ?: return val availableBalance = balanceState.balance ?: return @@ -253,7 +279,7 @@ class SwapViewModel( .setScale(tokenIn.decimals, RoundingMode.DOWN) .stripTrailingZeros() - quoteService.setAmount(amount) + setTokenAmount(amount, SwapAmountDirection.In) } fun onSelectTokenIn(token: Token) { @@ -265,6 +291,9 @@ class SwapViewModel( } fun onSwitchPairs() { + fiatInputDirection = null + fiatServiceIn.useTokenAmount() + fiatServiceOut.useTokenAmount() quoteService.switchPairs() } @@ -282,18 +311,58 @@ class SwapViewModel( fun createMissingTokens(tokens: Set) { viewModelScope.launch { walletUseCase.awaitWallets(tokens) - reQuote() + quoteService.invalidateAndReQuote() } } fun onUpdateSettings(settings: Map) = quoteService.setSwapSettings(settings) - fun onEnterFiatAmount(v: BigDecimal?) = fiatServiceIn.setFiatAmount(v) fun reQuote() = quoteService.reQuote() fun onActionStarted() = quoteService.onActionStarted() fun onActionCompleted() = quoteService.onActionCompleted() fun getCurrentQuote() = quoteState.quote - fun getSettings() = quoteService.getSwapSettings() + fun getSettings() = quoteService.swapSettings + + private fun setTokenAmount(value: BigDecimal?, direction: SwapAmountDirection) { + fiatInputDirection = null + deactivateOtherFiatInput(direction) + fiatService(direction).setInputAmount(value) + setQuoteAmount(value, direction) + } + + private fun setFiatAmount(value: BigDecimal?, direction: SwapAmountDirection) { + fiatInputDirection = direction + deactivateOtherFiatInput(direction) + fiatService(direction).setFiatAmount(value) + } + + private fun deactivateOtherFiatInput(direction: SwapAmountDirection) { + fiatService( + when (direction) { + SwapAmountDirection.In -> SwapAmountDirection.Out + SwapAmountDirection.Out -> SwapAmountDirection.In + } + ).useTokenAmount() + } + + private fun fiatService(direction: SwapAmountDirection) = when (direction) { + SwapAmountDirection.In -> fiatServiceIn + SwapAmountDirection.Out -> fiatServiceOut + } + + private fun setQuoteAmount(value: BigDecimal?, direction: SwapAmountDirection) { + when (direction) { + SwapAmountDirection.In -> quoteService.setAmountIn(value) + SwapAmountDirection.Out -> quoteService.setAmountOut(value) + } + } + + private val displayAmountOut: BigDecimal? + get() = when (quoteState.direction) { + SwapAmountDirection.In -> + quoteState.multiSwapRoute?.selectedLeg2Quote?.amountOut ?: quoteState.quote?.amountOut + SwapAmountDirection.Out -> quoteState.requestedAmountOut + } private suspend fun obtainWarningMessage(): TranslatableString? { val quote = quoteState.quote ?: return null @@ -335,6 +404,7 @@ class SwapViewModel( data class SwapUiState( val amountIn: BigDecimal?, + val displayAmountOut: BigDecimal?, val tokenIn: Token?, val tokenOut: Token?, val quoting: Boolean, @@ -358,23 +428,39 @@ data class SwapUiState( val fiatAmountOut: BigDecimal?, val fiatPriceImpact: BigDecimal?, val currency: Currency, - val fiatAmountInputEnabled: Boolean, + val fiatAmountInInputEnabled: Boolean, + val fiatAmountOutInputEnabled: Boolean, val fiatPriceImpactLevel: PriceImpactLevel?, val timeout: Boolean, val multiSwapRoute: MultiSwapRoute?, + val direction: SwapAmountDirection, + val requestedAmountOut: BigDecimal?, + val amountInMax: BigDecimal?, + val amountOutAccuracy: SwapAmountAccuracy, + val quoteCautions: List, ) { - val currentStep: SwapStep = when { - error != null -> SwapStep.Error(error) - tokenIn == null -> SwapStep.InputRequired(InputType.TokenIn) - tokenOut == null -> SwapStep.InputRequired(InputType.TokenOut) - amountIn == null || amountIn.compareTo(BigDecimal.ZERO) == 0 -> SwapStep.InputRequired( - InputType.Amount - ) - // No fresh quote for the current input yet (fetching or pending debounce) - keep loading - quoting || quote == null -> SwapStep.Quoting - quote.actionRequired != null -> SwapStep.ActionRequired(requireNotNull(quote.actionRequired)) - else -> SwapStep.Proceed - } + private val requestedAmount: BigDecimal? + get() = when (direction) { + SwapAmountDirection.In -> amountIn + SwapAmountDirection.Out -> requestedAmountOut + } + + val currentStep: SwapStep + get() { + val amount = requestedAmount + return when { + error != null -> SwapStep.Error(error) + tokenIn == null -> SwapStep.InputRequired(InputType.TokenIn) + tokenOut == null -> SwapStep.InputRequired(InputType.TokenOut) + amount == null || amount <= BigDecimal.ZERO -> + SwapStep.InputRequired(InputType.Amount) + // No fresh quote for the current input yet (fetching or pending debounce) - keep loading + quoting || quote == null -> SwapStep.Quoting + quote.actionRequired != null -> + SwapStep.ActionRequired(requireNotNull(quote.actionRequired)) + else -> SwapStep.Proceed + } + } } sealed class SwapStep { diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/exchange/MultiSwapExchangeViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/exchange/MultiSwapExchangeViewModel.kt index 7d48c7770ff..b120a87df26 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/exchange/MultiSwapExchangeViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/exchange/MultiSwapExchangeViewModel.kt @@ -12,6 +12,7 @@ import cash.p.terminal.entities.PendingMultiSwap import cash.p.terminal.modules.multiswap.MultiSwapOnChainMonitor import cash.p.terminal.modules.multiswap.PriceImpactLevel import cash.p.terminal.modules.multiswap.AssetFiatRateService +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.multiswap.SwapProviderQuote import cash.p.terminal.modules.multiswap.SwapQuoteService import cash.p.terminal.modules.multiswap.TimerService @@ -202,7 +203,8 @@ class MultiSwapExchangeViewModel( providers = enabledProviders, tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, + amount = amountIn, + direction = SwapAmountDirection.In, ) leg2Quoting = false leg2Quotes = quotes diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/exchanges/MultiSwapExchangesFragment.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/exchanges/MultiSwapExchangesFragment.kt index 6653760c905..66f3efbc8b4 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/exchanges/MultiSwapExchangesFragment.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/exchanges/MultiSwapExchangesFragment.kt @@ -23,6 +23,10 @@ import cash.p.terminal.core.composablePopup import cash.p.terminal.core.getKoinInstance import cash.p.terminal.core.usecase.ResolveTransactionItemUseCase import cash.p.terminal.modules.multiswap.MultiSwapLegInfo +import cash.p.terminal.modules.multiswap.SwapAmountDirection +import cash.p.terminal.modules.multiswap.SwapConfirmBalanceParams +import cash.p.terminal.modules.multiswap.SwapConfirmNavigation +import cash.p.terminal.modules.multiswap.SwapConfirmQuoteParams import cash.p.terminal.modules.multiswap.SwapConfirmScreen import cash.p.terminal.modules.multiswap.SwapSelectProviderScreen import cash.p.terminal.modules.multiswap.SwapSelectProviderViewModel @@ -246,7 +250,7 @@ private fun ExchangeDetailContent( } val selectProviderViewModel = viewModel( viewModelStoreOwner = backStackEntry, - factory = SwapSelectProviderViewModel.Factory(quotes) + factory = SwapSelectProviderViewModel.Factory(quotes, SwapAmountDirection.In) ) val swapProvidersRepository = remember { getKoinInstance() } val disabledIds by swapProvidersRepository.disabledIds.collectAsStateWithLifecycle() @@ -279,18 +283,24 @@ private fun ExchangeDetailContent( } val balanceState by viewModel.leg2BalanceStateFlow.collectAsStateWithLifecycle(viewModel.leg2BalanceStateFlow.value) SwapConfirmScreen( - fragmentNavController = fragmentNavController, - swapNavController = detailNavController, - quote = quote, - settings = emptyMap(), - provider = quote.provider, - displayBalance = balanceState.displayBalance, - balanceHidden = viewModel.leg2BalanceHidden, - feeToken = balanceState.feeToken, - feeCoinBalance = balanceState.feeCoinBalance, + navigation = SwapConfirmNavigation(fragmentNavController, detailNavController), + quoteParams = SwapConfirmQuoteParams( + quote = quote, + settings = emptyMap(), + direction = SwapAmountDirection.In, + requestedAmountOut = null, + multiSwapLegInfo = MultiSwapLegInfo.Leg2(swapId), + ), + balanceParams = SwapConfirmBalanceParams( + provider = quote.provider, + displayBalance = balanceState.displayBalance, + balanceHidden = viewModel.leg2BalanceHidden, + feeToken = balanceState.feeToken, + feeCoinBalance = balanceState.feeCoinBalance, + ), onToggleHideBalance = viewModel::toggleLeg2BalanceHidden, + onReapprove = {}, onOpenSettings = { detailNavController.navigate(Leg2TransactionSettingsRoute) }, - multiSwapLegInfo = MultiSwapLegInfo.Leg2(swapId), ) } composablePage { diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProvider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProvider.kt index 779eb6f9f1b..caa5a0fc897 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProvider.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProvider.kt @@ -256,12 +256,7 @@ object AllBridgeProvider : IMultiSwapProvider { getProxyAddress(bridgeAddress)?.let { proxyAddress -> val proxyFee = EvmSwapHelper.getAllBridgeProxyFee(proxyAddress, amountIn) - - resAmountIn - proxyFee - - if (resAmountIn < BigDecimal.ZERO) { - throw kotlin.Exception("Amount is less than required fee") - } + resAmountIn = subtractFee(resAmountIn, proxyFee) } if (feePaymentMethod == FeePaymentMethod.StableCoin) { @@ -271,12 +266,7 @@ object AllBridgeProvider : IMultiSwapProvider { ) val allbridgeFee = gasFee.stablecoin.float - - resAmountIn - allbridgeFee - - if (resAmountIn < BigDecimal.ZERO) { - throw kotlin.Exception("Amount is less than required fee") - } + resAmountIn = subtractFee(resAmountIn, allbridgeFee) } val amount = resAmountIn.movePointRight(tokenPairIn.abToken.decimals).toBigInteger() @@ -291,6 +281,11 @@ object AllBridgeProvider : IMultiSwapProvider { return pendingInfo.estimatedAmount.min.float } + internal fun subtractFee(amount: BigDecimal, fee: BigDecimal): BigDecimal { + return amount.subtract(fee).takeIf { it >= BigDecimal.ZERO } + ?: throw IllegalArgumentException("Amount is less than required fee") + } + override suspend fun fetchFinalQuote( tokenIn: Token, tokenOut: Token, diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapProvider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapProvider.kt index 5e71daea75f..07263cf5868 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapProvider.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapProvider.kt @@ -3,6 +3,7 @@ package cash.p.terminal.modules.multiswap.providers import cash.p.terminal.modules.multiswap.EvmBlockchainHelper import cash.p.terminal.modules.multiswap.ISwapFinalQuote import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.multiswap.SwapFinalQuoteEvm import cash.p.terminal.modules.multiswap.SwapQuoteUniswap import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData @@ -10,21 +11,22 @@ import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionSettings import cash.p.terminal.modules.multiswap.settings.SwapSettingDeadline import cash.p.terminal.modules.multiswap.settings.SwapSettingRecipient import cash.p.terminal.modules.multiswap.settings.SwapSettingSlippage +import cash.p.terminal.modules.multiswap.ui.DataField import cash.p.terminal.modules.multiswap.ui.DataFieldAllowance import cash.p.terminal.modules.multiswap.ui.DataFieldRecipient import cash.p.terminal.modules.multiswap.ui.DataFieldRecipientExtended import cash.p.terminal.modules.multiswap.ui.DataFieldSlippage import cash.p.terminal.wallet.Token +import cash.p.terminal.wallet.entities.TokenType import io.horizontalsystems.ethereumkit.models.Address import io.horizontalsystems.ethereumkit.models.Chain -import cash.p.terminal.wallet.entities.TokenType import io.horizontalsystems.uniswapkit.UniswapKit import io.horizontalsystems.uniswapkit.models.TradeData import io.horizontalsystems.uniswapkit.models.TradeOptions import kotlinx.coroutines.rx2.await import java.math.BigDecimal -abstract class BaseUniswapProvider : EvmSwapProvider() { +abstract class BaseUniswapProvider : EvmSwapProvider(), IExactOutSwapProvider { private val uniswapKit by lazy { UniswapKit.getInstance() } override val mevProtectionAvailable: Boolean = true @@ -33,131 +35,188 @@ abstract class BaseUniswapProvider : EvmSwapProvider() { tokenIn: Token, tokenOut: Token, amountIn: BigDecimal, - settings: Map - ): ISwapQuote { - val bestTrade = fetchBestTrade(tokenIn, tokenOut, amountIn, settings) + settings: Map, + ): ISwapQuote = fetchQuote(tokenIn, tokenOut, amountIn, settings, SwapAmountDirection.In) + + final override suspend fun fetchQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + settings: Map, + ): ISwapQuote = fetchQuote(tokenIn, tokenOut, amountOut, settings, SwapAmountDirection.Out) + + final override suspend fun supportsExactOut(tokenIn: Token, tokenOut: Token): Boolean = + supports(tokenIn, tokenOut) && isUniswapToken(tokenIn) && isUniswapToken(tokenOut) + private suspend fun fetchQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + direction: SwapAmountDirection, + ): ISwapQuote { + val bestTrade = fetchBestTrade(tokenIn, tokenOut, amount, settings, direction) + val amountIn = requireNotNull(bestTrade.tradeData.amountIn) + val inputRequired = requiredInput(amountIn, bestTrade.tradeData.amountInMax(direction)) val routerAddress = uniswapKit.routerAddress(bestTrade.chain) val allowance = getAllowance(tokenIn, routerAddress) - val fields = buildList { - bestTrade.settingRecipient.value?.let { - add(DataFieldRecipient(it)) - } - bestTrade.settingSlippage.value?.let { - add(DataFieldSlippage(it)) - } - if (allowance != null && allowance < amountIn) { - add(DataFieldAllowance(allowance, tokenIn)) - } - } - return SwapQuoteUniswap( - bestTrade.tradeData, - fields, - listOf(bestTrade.settingRecipient, bestTrade.settingSlippage, bestTrade.settingDeadline), - tokenIn, - tokenOut, - amountIn, - getCreateTokenActionRequired(listOf(tokenIn, tokenOut)) ?: actionApprove(allowance, amountIn, routerAddress, tokenIn) + tradeData = bestTrade.tradeData, + fields = quoteFields(bestTrade, allowance, inputRequired, tokenIn), + settings = bestTrade.settings, + tokenIn = tokenIn, + tokenOut = tokenOut, + amountIn = amountIn, + actionRequired = getCreateTokenActionRequired(listOf(tokenIn, tokenOut)) + ?: actionApprove(allowance, inputRequired, routerAddress, tokenIn), ) } - override suspend fun fetchFinalQuote( + final override suspend fun fetchFinalQuote( tokenIn: Token, tokenOut: Token, amountIn: BigDecimal, swapSettings: Map, sendTransactionSettings: SendTransactionSettings?, swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountIn, + swapSettings, + sendTransactionSettings, + SwapAmountDirection.In, + ) + + final override suspend fun fetchFinalQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + swapSettings: Map, + sendTransactionSettings: SendTransactionSettings?, + swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountOut, + swapSettings, + sendTransactionSettings, + SwapAmountDirection.Out, + ) + + private suspend fun fetchFinalQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + sendSettings: SendTransactionSettings?, + direction: SwapAmountDirection, ): ISwapFinalQuote { - check(sendTransactionSettings is SendTransactionSettings.Evm) - - val bestTrade = fetchBestTrade( - tokenIn, - tokenOut, - amountIn, - swapSettings - ) - - val transactionData = uniswapKit.transactionData( - sendTransactionSettings.receiveAddress, - bestTrade.chain, - bestTrade.tradeData - ) - - val slippage = bestTrade.settingSlippage.valueOrDefault() - val amountOut = bestTrade.tradeData.amountOut!! - val amountOutMin = amountOut - amountOut / BigDecimal(100) * slippage - - val fields = buildList { - bestTrade.settingRecipient.value?.let { - add(DataFieldRecipientExtended(it, tokenOut.blockchainType)) - } - bestTrade.settingSlippage.value?.let { - add(DataFieldSlippage(it)) - } - } + check(sendSettings is SendTransactionSettings.Evm) + val bestTrade = fetchBestTrade(tokenIn, tokenOut, amount, settings, direction) + val tradeData = bestTrade.tradeData + val amountIn = requireNotNull(tradeData.amountIn) + val amountInMax = tradeData.amountInMax(direction) + val inputRequired = requiredInput(amountIn, amountInMax) + val routerAddress = uniswapKit.routerAddress(bestTrade.chain) + val allowanceCaution = direction.takeIf { it == SwapAmountDirection.Out } + ?.let { insufficientAllowanceCaution(getAllowance(tokenIn, routerAddress), inputRequired) } return SwapFinalQuoteEvm( tokenIn = tokenIn, tokenOut = tokenOut, amountIn = amountIn, - amountOut = amountOut, - amountOutMin = amountOutMin, - sendTransactionData = SendTransactionData.Evm(transactionData, null, amount = amountIn), - priceImpact = bestTrade.tradeData.priceImpact, - fields = fields + amountOut = requireNotNull(tradeData.amountOut), + amountOutMin = tradeData.amountOutMin(direction, bestTrade.settingSlippage), + sendTransactionData = SendTransactionData.Evm( + transactionData = uniswapKit.transactionData( + sendSettings.receiveAddress, + bestTrade.chain, + tradeData, + ), + gasLimit = null, + amount = inputRequired, + ), + priceImpact = tradeData.priceImpact, + fields = finalFields(bestTrade, tokenOut), + amountInMax = amountInMax, + cautions = listOfNotNull(allowanceCaution), ) } - @Throws - private fun uniswapToken(token: Token?, chain: Chain) = when (val tokenType = token?.type) { - TokenType.Native -> uniswapKit.etherToken(chain) - is TokenType.Eip20 -> { - uniswapKit.token(Address(tokenType.address), token.decimals) - } - - else -> throw Exception("Invalid coin for swap: $token") - } - private suspend fun fetchBestTrade( tokenIn: Token, tokenOut: Token, - amountIn: BigDecimal, + amount: BigDecimal, settings: Map, + direction: SwapAmountDirection, ): UniswapBestTrade { - val blockchainType = tokenIn.blockchainType - val evmBlockchainHelper = EvmBlockchainHelper(blockchainType) - val chain = evmBlockchainHelper.chain - val rpcSourceHttp = evmBlockchainHelper.getRpcSourceHttp() - - val settingRecipient = SwapSettingRecipient(settings, tokenOut) - val settingSlippage = SwapSettingSlippage(settings, TradeOptions.defaultAllowedSlippage) - val settingDeadline = SwapSettingDeadline(settings, TradeOptions.defaultTtl) - - val tradeOptions = TradeOptions( - allowedSlippagePercent = settingSlippage.valueOrDefault(), - ttl = settingDeadline.valueOrDefault(), - recipient = settingRecipient.getEthereumKitAddress(), + val helper = EvmBlockchainHelper(tokenIn.blockchainType) + val chain = helper.chain + val recipient = SwapSettingRecipient(settings, tokenOut) + val slippage = SwapSettingSlippage(settings, TradeOptions.defaultAllowedSlippage) + val deadline = SwapSettingDeadline(settings, TradeOptions.defaultTtl) + val options = TradeOptions( + allowedSlippagePercent = slippage.valueOrDefault(), + ttl = deadline.valueOrDefault(), + recipient = recipient.getEthereumKitAddress(), ) - val swapData = uniswapKit.swapData( - rpcSourceHttp, + helper.getRpcSourceHttp(), chain, uniswapToken(tokenIn, chain), - uniswapToken(tokenOut, chain) + uniswapToken(tokenOut, chain), ).await() + val tradeData = when (direction) { + SwapAmountDirection.In -> uniswapKit.bestTradeExactIn(swapData, amount, options) + SwapAmountDirection.Out -> uniswapKit.bestTradeExactOut(swapData, amount, options) + } + return UniswapBestTrade(recipient, slippage, deadline, tradeData, chain) + } - val tradeData = uniswapKit.bestTradeExactIn(swapData, amountIn, tradeOptions) + private fun quoteFields( + trade: UniswapBestTrade, + allowance: BigDecimal?, + inputRequired: BigDecimal, + tokenIn: Token, + ): List = buildList { + trade.settingRecipient.value?.let { add(DataFieldRecipient(it)) } + trade.settingSlippage.value?.let { add(DataFieldSlippage(it)) } + if (allowance != null && allowance < inputRequired) { + add(DataFieldAllowance(allowance, tokenIn)) + } + } - return UniswapBestTrade( - settingRecipient, - settingSlippage, - settingDeadline, - tradeData, - chain - ) + private fun finalFields(trade: UniswapBestTrade, tokenOut: Token): List = buildList { + trade.settingRecipient.value?.let { + add(DataFieldRecipientExtended(it, tokenOut.blockchainType)) + } + trade.settingSlippage.value?.let { add(DataFieldSlippage(it)) } + } + + private fun isUniswapToken(token: Token): Boolean = + token.type == TokenType.Native || token.type is TokenType.Eip20 + + private fun uniswapToken(token: Token, chain: Chain) = when (val type = token.type) { + TokenType.Native -> uniswapKit.etherToken(chain) + is TokenType.Eip20 -> uniswapKit.token(Address(type.address), token.decimals) + else -> error("Invalid coin for swap: $token") + } + + private fun TradeData.amountInMax(direction: SwapAmountDirection): BigDecimal? = + amountInMax.takeIf { direction == SwapAmountDirection.Out } + + private fun TradeData.amountOutMin( + direction: SwapAmountDirection, + slippage: SwapSettingSlippage, + ): BigDecimal { + val amountOut = requireNotNull(amountOut) + return when (direction) { + SwapAmountDirection.In -> + amountOut - amountOut / BigDecimal(100) * slippage.valueOrDefault() + SwapAmountDirection.Out -> amountOut + } } } @@ -166,5 +225,7 @@ private data class UniswapBestTrade( val settingSlippage: SwapSettingSlippage, val settingDeadline: SwapSettingDeadline, val tradeData: TradeData, - val chain: Chain -) + val chain: Chain, +) { + val settings = listOf(settingRecipient, settingSlippage, settingDeadline) +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapV3Provider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapV3Provider.kt index dfd0a6ee55b..e53cf4d8ae7 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapV3Provider.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/BaseUniswapV3Provider.kt @@ -3,6 +3,7 @@ package cash.p.terminal.modules.multiswap.providers import cash.p.terminal.modules.multiswap.EvmBlockchainHelper import cash.p.terminal.modules.multiswap.ISwapFinalQuote import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.multiswap.SwapFinalQuoteEvm import cash.p.terminal.modules.multiswap.SwapQuoteUniswapV3 import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData @@ -10,21 +11,25 @@ import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionSettings import cash.p.terminal.modules.multiswap.settings.SwapSettingDeadline import cash.p.terminal.modules.multiswap.settings.SwapSettingRecipient import cash.p.terminal.modules.multiswap.settings.SwapSettingSlippage +import cash.p.terminal.modules.multiswap.ui.DataField import cash.p.terminal.modules.multiswap.ui.DataFieldAllowance import cash.p.terminal.modules.multiswap.ui.DataFieldRecipient import cash.p.terminal.modules.multiswap.ui.DataFieldRecipientExtended import cash.p.terminal.modules.multiswap.ui.DataFieldSlippage import cash.p.terminal.wallet.Token -import io.horizontalsystems.ethereumkit.models.Chain -import io.horizontalsystems.core.entities.BlockchainType import cash.p.terminal.wallet.entities.TokenType +import io.horizontalsystems.core.entities.BlockchainType +import io.horizontalsystems.ethereumkit.models.Address +import io.horizontalsystems.ethereumkit.models.Chain import io.horizontalsystems.uniswapkit.UniswapV3Kit import io.horizontalsystems.uniswapkit.models.DexType import io.horizontalsystems.uniswapkit.models.TradeOptions import io.horizontalsystems.uniswapkit.v3.TradeDataV3 import java.math.BigDecimal -abstract class BaseUniswapV3Provider(dexType: DexType) : EvmSwapProvider() { +abstract class BaseUniswapV3Provider( + dexType: DexType, +) : EvmSwapProvider(), IExactOutSwapProvider { private val uniswapV3Kit by lazy { UniswapV3Kit.getInstance(dexType) } override val mevProtectionAvailable: Boolean = true @@ -33,125 +38,179 @@ abstract class BaseUniswapV3Provider(dexType: DexType) : EvmSwapProvider() { tokenIn: Token, tokenOut: Token, amountIn: BigDecimal, - settings: Map - ): ISwapQuote { - val bestTrade = fetchBestTrade(tokenIn, tokenOut, amountIn, settings) + settings: Map, + ): ISwapQuote = fetchQuote(tokenIn, tokenOut, amountIn, settings, SwapAmountDirection.In) + final override suspend fun fetchQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + settings: Map, + ): ISwapQuote = fetchQuote(tokenIn, tokenOut, amountOut, settings, SwapAmountDirection.Out) + + final override suspend fun supportsExactOut(tokenIn: Token, tokenOut: Token): Boolean = + supports(tokenIn, tokenOut) && isUniswapToken(tokenIn) && isUniswapToken(tokenOut) + + private suspend fun fetchQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + direction: SwapAmountDirection, + ): ISwapQuote { + val bestTrade = fetchBestTrade(tokenIn, tokenOut, amount, settings, direction) + val amountIn = requireNotNull(bestTrade.tradeData.tokenAmountIn.decimalAmount) + val amountInMax = bestTrade.tradeData.amountInMax(direction) + val inputRequired = requiredInput(amountIn, amountInMax) val routerAddress = uniswapV3Kit.routerAddress(bestTrade.chain) val allowance = getAllowance(tokenIn, routerAddress) - val fields = buildList { - bestTrade.settingRecipient.value?.let { - add(DataFieldRecipient(it)) - } - bestTrade.settingSlippage.value?.let { - add(DataFieldSlippage(it)) - } - if (allowance != null && allowance < amountIn) { - add(DataFieldAllowance(allowance, tokenIn)) - } - } - return SwapQuoteUniswapV3( - bestTrade.tradeDataV3, - fields, - listOf(bestTrade.settingRecipient, bestTrade.settingSlippage, bestTrade.settingDeadline), - tokenIn, - tokenOut, - amountIn, - getCreateTokenActionRequired(listOf(tokenIn, tokenOut)) ?: actionApprove(allowance, amountIn, routerAddress, tokenIn) + tradeDataV3 = bestTrade.tradeData, + fields = quoteFields(bestTrade, allowance, inputRequired, tokenIn), + settings = bestTrade.settings, + tokenIn = tokenIn, + tokenOut = tokenOut, + amountIn = amountIn, + actionRequired = getCreateTokenActionRequired(listOf(tokenIn, tokenOut)) + ?: actionApprove(allowance, inputRequired, routerAddress, tokenIn), ) } - override suspend fun fetchFinalQuote( + final override suspend fun fetchFinalQuote( tokenIn: Token, tokenOut: Token, amountIn: BigDecimal, swapSettings: Map, sendTransactionSettings: SendTransactionSettings?, swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountIn, + swapSettings, + sendTransactionSettings, + SwapAmountDirection.In, + ) + + final override suspend fun fetchFinalQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + swapSettings: Map, + sendTransactionSettings: SendTransactionSettings?, + swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountOut, + swapSettings, + sendTransactionSettings, + SwapAmountDirection.Out, + ) + + private suspend fun fetchFinalQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + sendSettings: SendTransactionSettings?, + direction: SwapAmountDirection, ): ISwapFinalQuote { - check(sendTransactionSettings is SendTransactionSettings.Evm) - - val bestTrade = fetchBestTrade( - tokenIn, - tokenOut, - amountIn, - swapSettings - ) - - val transactionData = uniswapV3Kit.transactionData( - sendTransactionSettings.receiveAddress, - bestTrade.chain, - bestTrade.tradeDataV3 - ) - - val slippage = bestTrade.settingSlippage.valueOrDefault() - val amountOut = bestTrade.tradeDataV3.tokenAmountOut.decimalAmount!! - val amountOutMin = amountOut - amountOut / BigDecimal(100) * slippage - - val fields = buildList { - bestTrade.settingRecipient.value?.let { - add(DataFieldRecipientExtended(it, tokenOut.blockchainType)) - } - bestTrade.settingSlippage.value?.let { - add(DataFieldSlippage(it)) - } - } + check(sendSettings is SendTransactionSettings.Evm) + val bestTrade = fetchBestTrade(tokenIn, tokenOut, amount, settings, direction) + val tradeData = bestTrade.tradeData + val amountIn = requireNotNull(tradeData.tokenAmountIn.decimalAmount) + val amountInMax = tradeData.amountInMax(direction) + val inputRequired = requiredInput(amountIn, amountInMax) + val routerAddress = uniswapV3Kit.routerAddress(bestTrade.chain) + val allowanceCaution = direction.takeIf { it == SwapAmountDirection.Out } + ?.let { insufficientAllowanceCaution(getAllowance(tokenIn, routerAddress), inputRequired) } return SwapFinalQuoteEvm( tokenIn = tokenIn, tokenOut = tokenOut, amountIn = amountIn, - amountOut = amountOut, - amountOutMin = amountOutMin, - sendTransactionData = SendTransactionData.Evm(transactionData, null, amount = amountIn), - priceImpact = bestTrade.tradeDataV3.priceImpact, - fields = fields + amountOut = requireNotNull(tradeData.tokenAmountOut.decimalAmount), + amountOutMin = tradeData.amountOutMin(direction, bestTrade.settingSlippage), + sendTransactionData = SendTransactionData.Evm( + transactionData = uniswapV3Kit.transactionData( + sendSettings.receiveAddress, + bestTrade.chain, + tradeData, + ), + gasLimit = null, + amount = inputRequired, + ), + priceImpact = tradeData.priceImpact, + fields = finalFields(bestTrade, tokenOut), + amountInMax = amountInMax, + cautions = listOfNotNull(allowanceCaution), ) } private suspend fun fetchBestTrade( tokenIn: Token, tokenOut: Token, - amountIn: BigDecimal, + amount: BigDecimal, settings: Map, + direction: SwapAmountDirection, ): UniswapV3BestTrade { - val blockchainType = tokenIn.blockchainType - val evmBlockchainHelper = EvmBlockchainHelper(blockchainType) - val chain = evmBlockchainHelper.chain - val rpcSourceHttp = evmBlockchainHelper.getRpcSourceHttp() - - val settingRecipient = SwapSettingRecipient(settings, tokenOut) - val settingSlippage = SwapSettingSlippage(settings, TradeOptions.defaultAllowedSlippage) - val settingDeadline = SwapSettingDeadline(settings, TradeOptions.defaultTtl) - - val tradeOptions = TradeOptions( - allowedSlippagePercent = settingSlippage.valueOrDefault(), - ttl = settingDeadline.valueOrDefault(), - recipient = settingRecipient.getEthereumKitAddress(), + val helper = EvmBlockchainHelper(tokenIn.blockchainType) + val chain = helper.chain + val recipient = SwapSettingRecipient(settings, tokenOut) + val slippage = SwapSettingSlippage(settings, TradeOptions.defaultAllowedSlippage) + val deadline = SwapSettingDeadline(settings, TradeOptions.defaultTtl) + val options = TradeOptions( + allowedSlippagePercent = slippage.valueOrDefault(), + ttl = deadline.valueOrDefault(), + recipient = recipient.getEthereumKitAddress(), ) + val tradeData = when (direction) { + SwapAmountDirection.In -> uniswapV3Kit.bestTradeExactIn( + helper.getRpcSourceHttp(), + chain, + uniswapToken(tokenIn, chain), + uniswapToken(tokenOut, chain), + amount, + options, + ) + SwapAmountDirection.Out -> uniswapV3Kit.bestTradeExactOut( + helper.getRpcSourceHttp(), + chain, + uniswapToken(tokenIn, chain), + uniswapToken(tokenOut, chain), + amount, + options, + ) + } + return UniswapV3BestTrade(recipient, slippage, deadline, tradeData, chain) + } - val tradeDataV3 = uniswapV3Kit.bestTradeExactIn( - rpcSourceHttp, - chain, - uniswapToken(tokenIn, chain), - uniswapToken(tokenOut, chain), - amountIn, - tradeOptions, - ) + private fun quoteFields( + trade: UniswapV3BestTrade, + allowance: BigDecimal?, + inputRequired: BigDecimal, + tokenIn: Token, + ): List = buildList { + trade.settingRecipient.value?.let { add(DataFieldRecipient(it)) } + trade.settingSlippage.value?.let { add(DataFieldSlippage(it)) } + if (allowance != null && allowance < inputRequired) { + add(DataFieldAllowance(allowance, tokenIn)) + } + } - return UniswapV3BestTrade( - settingRecipient, - settingSlippage, - settingDeadline, - tradeDataV3, - chain - ) + private fun finalFields(trade: UniswapV3BestTrade, tokenOut: Token): List = buildList { + trade.settingRecipient.value?.let { + add(DataFieldRecipientExtended(it, tokenOut.blockchainType)) + } + trade.settingSlippage.value?.let { add(DataFieldSlippage(it)) } } - @Throws - private fun uniswapToken(token: Token?, chain: Chain) = when (val tokenType = token?.type) { + private fun isUniswapToken(token: Token): Boolean = + token.type == TokenType.Native || token.type is TokenType.Eip20 + + private fun uniswapToken(token: Token, chain: Chain) = when (val type = token.type) { TokenType.Native -> when (token.blockchainType) { BlockchainType.Ethereum, BlockchainType.BinanceSmartChain, @@ -159,14 +218,27 @@ abstract class BaseUniswapV3Provider(dexType: DexType) : EvmSwapProvider() { BlockchainType.Optimism, BlockchainType.Base, BlockchainType.ZkSync, - BlockchainType.ArbitrumOne -> uniswapV3Kit.etherToken(chain) - else -> throw Exception("Invalid coin for swap: $token") + BlockchainType.ArbitrumOne, + -> uniswapV3Kit.etherToken(chain) + else -> error("Invalid coin for swap: $token") + } + is TokenType.Eip20 -> uniswapV3Kit.token(Address(type.address), token.decimals) + else -> error("Invalid coin for swap: $token") + } + + private fun TradeDataV3.amountInMax(direction: SwapAmountDirection): BigDecimal? = + tokenAmountInMaximum.decimalAmount.takeIf { direction == SwapAmountDirection.Out } + + private fun TradeDataV3.amountOutMin( + direction: SwapAmountDirection, + slippage: SwapSettingSlippage, + ): BigDecimal { + val amountOut = requireNotNull(tokenAmountOut.decimalAmount) + return when (direction) { + SwapAmountDirection.In -> + amountOut - amountOut / BigDecimal(100) * slippage.valueOrDefault() + SwapAmountDirection.Out -> amountOut } - is TokenType.Eip20 -> uniswapV3Kit.token( - io.horizontalsystems.ethereumkit.models.Address( - tokenType.address - ), token.decimals) - else -> throw Exception("Invalid coin for swap: $token") } } @@ -174,6 +246,8 @@ private data class UniswapV3BestTrade( val settingRecipient: SwapSettingRecipient, val settingSlippage: SwapSettingSlippage, val settingDeadline: SwapSettingDeadline, - val tradeDataV3: TradeDataV3, - val chain: Chain -) + val tradeData: TradeDataV3, + val chain: Chain, +) { + val settings = listOf(settingRecipient, settingSlippage, settingDeadline) +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/IExactOutSwapProvider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/IExactOutSwapProvider.kt new file mode 100644 index 00000000000..7eb57154640 --- /dev/null +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/IExactOutSwapProvider.kt @@ -0,0 +1,35 @@ +package cash.p.terminal.modules.multiswap.providers + +import cash.p.terminal.modules.multiswap.ISwapFinalQuote +import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionSettings +import cash.p.terminal.wallet.Token +import java.math.BigDecimal + +/** + * Optional capability for providers that can quote a requested output amount natively. + * Pair support does not imply support for this execution mode. + */ +interface IExactOutSwapProvider { + val exactOutAccuracy: SwapAmountAccuracy + get() = SwapAmountAccuracy.Exact + + suspend fun supportsExactOut(tokenIn: Token, tokenOut: Token): Boolean + + suspend fun fetchQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + settings: Map, + ): ISwapQuote + + suspend fun fetchFinalQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + swapSettings: Map, + sendTransactionSettings: SendTransactionSettings?, + swapQuote: ISwapQuote, + ): ISwapFinalQuote +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/StonFiProvider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/StonFiProvider.kt index c6d620ba002..3fe72685bb8 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/StonFiProvider.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/StonFiProvider.kt @@ -3,11 +3,14 @@ package cash.p.terminal.modules.multiswap.providers import cash.p.terminal.R import cash.p.terminal.core.HSCaution import cash.p.terminal.core.providers.AppConfigProvider +import cash.p.terminal.core.tryOrNull import cash.p.terminal.modules.multiswap.ISwapFinalQuote import cash.p.terminal.modules.multiswap.ISwapQuote import cash.p.terminal.modules.multiswap.StonFiGasParams import cash.p.terminal.modules.multiswap.StonFiSwapData import cash.p.terminal.modules.multiswap.SwapQuoteStonFi +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionSettings import cash.p.terminal.modules.multiswap.settings.SwapSettingRecipient @@ -16,6 +19,7 @@ import cash.p.terminal.modules.multiswap.ui.DataField import cash.p.terminal.modules.multiswap.ui.DataFieldRecipient import cash.p.terminal.modules.multiswap.ui.DataFieldSlippage import cash.p.terminal.network.stonfi.domain.entity.SimulateSwap +import cash.p.terminal.network.stonfi.domain.entity.RouterInfo import cash.p.terminal.network.stonfi.domain.repository.StonFiRepository import cash.p.terminal.wallet.Token import cash.p.terminal.wallet.entities.TokenType @@ -24,6 +28,7 @@ import com.tonapps.blockchain.ton.extensions.toByteArray import io.horizontalsystems.core.entities.BlockchainType import io.ktor.util.encodeBase64 import org.ton.block.AddrStd +import org.ton.cell.Cell import timber.log.Timber import java.math.BigDecimal import java.math.BigInteger @@ -31,10 +36,11 @@ import java.math.BigInteger class StonFiProvider( private val stonFiRepository: StonFiRepository, override val walletUseCase: WalletUseCase, -) : IMultiSwapProvider { +) : IMultiSwapProvider, IExactOutSwapProvider { override val id = "stonfi" override val title = "STON.fi" override val icon = R.drawable.ic_ston_fi + override val exactOutAccuracy = SwapAmountAccuracy.AtLeast override val mevProtectionAvailable: Boolean = false // TON native token address @@ -64,76 +70,101 @@ class StonFiProvider( tokenOut: Token, amountIn: BigDecimal, settings: Map - ): ISwapQuote { - val settingRecipient = SwapSettingRecipient(settings, tokenOut) - val settingSlippage = - SwapSettingSlippage(settings, SLIPPAGE) + ): ISwapQuote = fetchQuote( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amountIn, + settings = settings, + direction = SwapAmountDirection.In, + ) + + override suspend fun supportsExactOut(tokenIn: Token, tokenOut: Token): Boolean = + supports(tokenIn, tokenOut) && tryOrNull { + getTokenAddress(tokenIn) + getTokenAddress(tokenOut) + } != null - val offerAddress = getTokenAddress(tokenIn) - val askAddress = getTokenAddress(tokenOut) - val units = amountIn.movePointRight(tokenIn.decimals).toBigInteger().toString() - val referralAddressTon = REF_ADDRESS_TON - val referralFeeBps = referralAddressTon?.let { REF_FEE_BPS } + override suspend fun fetchQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + settings: Map, + ): ISwapQuote = fetchQuote( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amountOut, + settings = settings, + direction = SwapAmountDirection.Out, + ) + private suspend fun fetchQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + direction: SwapAmountDirection, + ): ISwapQuote { + val settingRecipient = SwapSettingRecipient(settings, tokenOut) + val settingSlippage = SwapSettingSlippage(settings, SLIPPAGE) val simulation = simulateSwapWithFallback( - offerAddress = offerAddress, - askAddress = askAddress, - units = units, - slippageTolerance = settingSlippage.valueOrDefault(), - poolAddress = null, - referralAddress = referralAddressTon, - referralFeeBps = referralFeeBps, - preferredVersions = listOf(2, 1) + request = simulationRequest( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amount, + slippage = settingSlippage.valueOrDefault(), + direction = direction, + ), + preferredVersions = listOf(2, 1), ) - val response = simulation.swap - val dexVersionUsed = simulation.dexVersion - + val amountIn = response.offerUnits.toBigDecimal().movePointLeft(tokenIn.decimals) val amountOut = BigDecimal(response.askUnits).movePointLeft(tokenOut.decimals) - val priceImpact = BigDecimal(response.priceImpact) - - val swapData = StonFiSwapData( - offerAddress = response.offerAddress, - askAddress = response.askAddress, - offerJettonWallet = response.offerJettonWallet, - askJettonWallet = response.askJettonWallet, - routerAddress = response.routerAddress, - poolAddress = response.poolAddress, - offerUnits = response.offerUnits, - askUnits = response.askUnits, - slippageTolerance = response.slippageTolerance, - minAskUnits = response.minAskUnits, - swapRate = response.swapRate, - priceImpact = response.priceImpact, - feeAddress = response.feeAddress, - feeUnits = response.feeUnits, - feePercent = response.feePercent, - gasParams = StonFiGasParams( - forwardGas = response.gasParams.forwardGas, - estimatedGasConsumption = response.gasParams.estimatedGasConsumption, - gasBudget = response.gasParams.gasBudget - ), - dexVersion = dexVersionUsed - ) - - val fields = buildList { - settingRecipient.value?.let { add(DataFieldRecipient(it)) } - settingSlippage.value?.let { add(DataFieldSlippage(it)) } - } return SwapQuoteStonFi( amountOut = amountOut, - priceImpact = priceImpact, - fields = fields, + priceImpact = BigDecimal(response.priceImpact), + fields = quoteFields(settingRecipient, settingSlippage), settings = listOf(settingRecipient, settingSlippage), tokenIn = tokenIn, tokenOut = tokenOut, amountIn = amountIn, actionRequired = getCreateTokenActionRequired(listOf(tokenIn, tokenOut)), - swapData = swapData + swapData = swapData(response, simulation.dexVersion), ) } + private fun swapData(response: SimulateSwap, dexVersion: Int) = StonFiSwapData( + offerAddress = response.offerAddress, + askAddress = response.askAddress, + offerJettonWallet = response.offerJettonWallet, + askJettonWallet = response.askJettonWallet, + routerAddress = response.routerAddress, + poolAddress = response.poolAddress, + offerUnits = response.offerUnits, + askUnits = response.askUnits, + slippageTolerance = response.slippageTolerance, + minAskUnits = response.minAskUnits, + swapRate = response.swapRate, + priceImpact = response.priceImpact, + feeAddress = response.feeAddress, + feeUnits = response.feeUnits, + feePercent = response.feePercent, + gasParams = StonFiGasParams( + forwardGas = response.gasParams.forwardGas, + estimatedGasConsumption = response.gasParams.estimatedGasConsumption, + gasBudget = response.gasParams.gasBudget, + ), + dexVersion = dexVersion, + ) + + private fun quoteFields( + recipient: SwapSettingRecipient, + slippage: SwapSettingSlippage, + ): List = buildList { + recipient.value?.let { add(DataFieldRecipient(it)) } + slippage.value?.let { add(DataFieldSlippage(it)) } + } + override suspend fun fetchFinalQuote( tokenIn: Token, tokenOut: Token, @@ -141,199 +172,299 @@ class StonFiProvider( swapSettings: Map, sendTransactionSettings: SendTransactionSettings?, swapQuote: ISwapQuote + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amountIn, + swapSettings = swapSettings, + swapQuote = swapQuote, + direction = SwapAmountDirection.In, + ) + + override suspend fun fetchFinalQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + swapSettings: Map, + sendTransactionSettings: SendTransactionSettings?, + swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amountOut, + swapSettings = swapSettings, + swapQuote = swapQuote, + direction = SwapAmountDirection.Out, + ) + + private suspend fun fetchFinalQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + swapSettings: Map, + swapQuote: ISwapQuote, + direction: SwapAmountDirection, ): ISwapFinalQuote { check(swapQuote is SwapQuoteStonFi) - val settingRecipient = SwapSettingRecipient(swapSettings, tokenOut) val settingSlippage = SwapSettingSlippage(swapSettings, SLIPPAGE) - // Get fresh quote for final transaction - val offerAddress = getTokenAddress(tokenIn) - val askAddress = getTokenAddress(tokenOut) - val units = amountIn.movePointRight(tokenIn.decimals).toBigInteger().toString() - - val preferredDexVersion = swapQuote.swapData.dexVersion - val versionsToTry = if (preferredDexVersion == 2) listOf(2, 1) else listOf(1, 2) - - val finalSimulation = simulateSwapWithFallback( - offerAddress = offerAddress, - askAddress = askAddress, - units = units, - slippageTolerance = settingSlippage.valueOrDefault(), - poolAddress = swapQuote.swapData.poolAddress.takeIf { it.isNotBlank() }, - referralAddress = REF_ADDRESS_TON, - referralFeeBps = REF_FEE_BPS, - preferredVersions = versionsToTry + val finalSimulation = simulateFreshFinalSwap( + tokenIn, + tokenOut, + amount, + settingSlippage.valueOrDefault(), + direction, + swapQuote, ) - val response = finalSimulation.swap - - val amountOut = BigDecimal(response.askUnits).movePointLeft(tokenOut.decimals) - val minAmountOut = BigDecimal(response.minAskUnits).movePointLeft(tokenOut.decimals) - - val fields = buildList { - settingRecipient.value?.let { add(DataFieldRecipient(it)) } - settingSlippage.value?.let { add(DataFieldSlippage(it)) } - } - + val amounts = finalQuoteAmounts( + tokenIn = tokenIn, + tokenOut = tokenOut, + targetAmount = amount, + direction = direction, + response = response, + ) val addressFrom = walletUseCase.getReceiveAddress(tokenIn) - val walletAddressTo = walletUseCase.getReceiveAddress(tokenOut) - val receiverOwnerAddress = settingRecipient.value?.hex ?: walletAddressTo - + val receiverOwnerAddress = settingRecipient.value?.hex ?: walletUseCase.getReceiveAddress(tokenOut) val routerInfo = stonFiRepository.getRouter(response.routerAddress) - - val ptonWalletAddress = when { - // jetton -> ... - tokenIn.type is TokenType.Jetton -> runCatching { - stonFiRepository.getJettonAddress( - contractAddress = (tokenIn.type as TokenType.Jetton).address, - ownerAddress = receiverOwnerAddress - ) - }.getOrNull() - - // ton -> ... - else -> routerInfo.ptonWalletAddress - } - + val ptonWalletAddress = ptonWalletAddress(tokenIn, receiverOwnerAddress, routerInfo) val destinationAddress = when { finalSimulation.dexVersion == 1 && tokenIn.type == TokenType.Native -> response.offerJettonWallet .takeUnless { it.isBlank() } - ?: throw IllegalStateException("STON.fi v1: missing offer jetton wallet") + ?: error("STON.fi v1: missing offer jetton wallet") else -> ptonWalletAddress } - val tonTransferQueryId = System.currentTimeMillis() + val payload = tonSwapPayload( + TonSwapPayloadRequest( + tokenIn = tokenIn, + amountIn = amounts.amountIn, + response = response, + dexVersion = finalSimulation.dexVersion, + addressFrom = addressFrom, + receiverOwnerAddress = receiverOwnerAddress, + routerInfo = routerInfo, + queryId = tonTransferQueryId, + minimumAskUnits = amounts.minimumAskUnits, + ) + ) + val sendTransactionData = sendTransactionData( + response, + routerInfo, + destinationAddress, + tonTransferQueryId, + settingSlippage.valueOrDefault(), + payload, + ) + return finalQuote( + amounts = amounts, + response = response, + sendTransactionData = sendTransactionData, + fields = quoteFields(settingRecipient, settingSlippage), + ) + } - var gasBudget = response.gasParams.gasBudget - - val swapPayload = when { - tokenIn.type is TokenType.Jetton -> { - when (finalSimulation.dexVersion) { - 1 -> { - gasBudget = response.offerUnits + response.gasParams.forwardGas + BigInteger("100000000") // 0.1 TON - - buildJettonToTonPayloadV1( - router = AddrStd(response.routerAddress), - refundAddress = AddrStd(addressFrom), - routerPtonWallet = AddrStd(routerInfo.ptonWalletAddress), - amount = amountIn.movePointRight(tokenIn.decimals).toBigInteger(), - minOut = BigInteger(response.minAskUnits), - queryId = tonTransferQueryId, - referralAddress = REF_ADDRESS_TON?.let { AddrStd(it) }, - forwardTonAmount = response.gasParams.forwardGas - ) - } - - 2 -> { - buildJettonToTonPayloadV2( - amount = amountIn.movePointRight(tokenIn.decimals).toBigInteger(), - router = AddrStd(response.routerAddress), - ptonWallet = AddrStd(response.askJettonWallet), - refundAddress = AddrStd(addressFrom), - minOut = BigInteger(response.minAskUnits), - forwardGas = response.gasParams.forwardGas, - queryId = tonTransferQueryId, - refFee = REF_FEE_BPS, - referralAddress = REF_ADDRESS_TON?.let { AddrStd(it) } - ) - } - - else -> { - throw IllegalStateException("Unsupported dex version: ${finalSimulation.dexVersion}") - } - } - } - - else -> { - - when (finalSimulation.dexVersion) { - 1 -> { - // amount to send - gasBudget = response.offerUnits + BigInteger("185000000") // 0.185 TON - val routerJettonWallet = response.askJettonWallet - .takeUnless { it.isNullOrBlank() } - ?: throw IllegalStateException("STON.fi v1: missing ask jetton wallet") - buildStonfiSwapTonToJettonTransferV1( - amount = amountIn.movePointRight(tokenIn.decimals).toBigInteger(), - routerAddress = AddrStd(response.routerAddress), - routerJettonWallet = AddrStd(routerJettonWallet), - receiver = AddrStd(receiverOwnerAddress), - minOut = BigInteger(response.minAskUnits), - referralAddress = REF_ADDRESS_TON?.let { AddrStd(it) }, - forwardTonAmount = response.gasParams.forwardGas, - queryId = tonTransferQueryId, - ) - } - - 2 -> { - buildStonfiSwapTonToJettonPayloadV2( - tonAmount = response.offerUnits, - tokenWallet = AddrStd(response.askJettonWallet), - refundAddress = AddrStd(addressFrom), - minOut = BigInteger(response.minAskUnits), - receiver = AddrStd(receiverOwnerAddress), - refFee = REF_FEE_BPS, - fwdGas = response.gasParams.forwardGas, - referralAddress = REF_ADDRESS_TON?.let { AddrStd(it) } - ) - } else -> { - throw IllegalStateException("Unsupported dex version: ${finalSimulation.dexVersion}") - } - } - - } - } + private suspend fun simulateFreshFinalSwap( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + slippage: BigDecimal, + direction: SwapAmountDirection, + swapQuote: SwapQuoteStonFi, + ) = simulateSwapWithFallback( + request = simulationRequest( + tokenIn = tokenIn, + tokenOut = tokenOut, + amount = amount, + slippage = slippage, + direction = direction, + poolAddress = swapQuote.swapData.poolAddress.takeIf(String::isNotBlank), + ), + preferredVersions = preferredVersions(swapQuote.swapData.dexVersion), + ) - val sendTransactionData = SendTransactionData.TonSwap( - offerUnits = response.offerUnits, - forwardGas = response.gasParams.forwardGas, - routerAddress = response.routerAddress, - routerMasterAddress = routerInfo.ptonMasterAddress, - destinationAddress = destinationAddress, - queryId = tonTransferQueryId, - slippage = settingSlippage.valueOrDefault(), - payload = swapPayload.toByteArray().encodeBase64(), - gasBudget = gasBudget + private fun finalQuoteAmounts( + tokenIn: Token, + tokenOut: Token, + targetAmount: BigDecimal, + direction: SwapAmountDirection, + response: SimulateSwap, + ): FinalQuoteAmounts { + val minimumAskUnits = stonFiMinimumAskUnits( + direction, + targetAmount, + tokenOut.decimals, + response.minAskUnits, ) - - return SwapFinalQuoteTon( + return FinalQuoteAmounts( tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, - amountOut = amountOut, - amountOutMin = minAmountOut, - sendTransactionData = sendTransactionData, - priceImpact = BigDecimal(response.priceImpact), - fields = fields + amountIn = response.offerUnits.toBigDecimal().movePointLeft(tokenIn.decimals), + amountOut = BigDecimal(response.askUnits).movePointLeft(tokenOut.decimals), + minimumAmountOut = minimumAskUnits.toBigDecimal().movePointLeft(tokenOut.decimals), + minimumAskUnits = minimumAskUnits, ) } + private fun preferredVersions(preferred: Int): List = + if (preferred == 2) listOf(2, 1) else listOf(1, 2) + + private fun sendTransactionData( + response: SimulateSwap, + routerInfo: RouterInfo, + destinationAddress: String?, + queryId: Long, + slippage: BigDecimal, + payload: TonSwapPayload, + ) = SendTransactionData.TonSwap( + offerUnits = response.offerUnits, + forwardGas = response.gasParams.forwardGas, + routerAddress = response.routerAddress, + routerMasterAddress = routerInfo.ptonMasterAddress, + destinationAddress = destinationAddress, + queryId = queryId, + slippage = slippage, + payload = payload.cell.toByteArray().encodeBase64(), + gasBudget = payload.gasBudget, + ) + + private fun finalQuote( + amounts: FinalQuoteAmounts, + response: SimulateSwap, + sendTransactionData: SendTransactionData, + fields: List, + ) = SwapFinalQuoteTon( + tokenIn = amounts.tokenIn, + tokenOut = amounts.tokenOut, + amountIn = amounts.amountIn, + amountOut = amounts.amountOut, + amountOutMin = amounts.minimumAmountOut, + sendTransactionData = sendTransactionData, + priceImpact = BigDecimal(response.priceImpact), + fields = fields, + ) + + private suspend fun ptonWalletAddress( + tokenIn: Token, + receiverOwnerAddress: String, + routerInfo: RouterInfo, + ): String? = when (val tokenType = tokenIn.type) { + is TokenType.Jetton -> tryOrNull { + stonFiRepository.getJettonAddress(tokenType.address, receiverOwnerAddress) + } + else -> routerInfo.ptonWalletAddress + } + + private fun tonSwapPayload(request: TonSwapPayloadRequest): TonSwapPayload = + if (request.tokenIn.type is TokenType.Jetton) { + jettonSwapPayload(request) + } else { + nativeTonSwapPayload(request) + } + + private fun jettonSwapPayload(request: TonSwapPayloadRequest): TonSwapPayload { + val response = request.response + val amountUnits = request.amountIn.movePointRight(request.tokenIn.decimals).toBigInteger() + return when (request.dexVersion) { + 1 -> TonSwapPayload( + cell = buildJettonToTonPayloadV1( + router = AddrStd(response.routerAddress), + refundAddress = AddrStd(request.addressFrom), + routerPtonWallet = AddrStd(request.routerInfo.ptonWalletAddress), + amount = amountUnits, + minOut = request.minimumAskUnits, + queryId = request.queryId, + referralAddress = REF_ADDRESS_TON?.let(::AddrStd), + forwardTonAmount = response.gasParams.forwardGas, + ), + gasBudget = response.offerUnits + response.gasParams.forwardGas + BigInteger("100000000"), + ) + 2 -> TonSwapPayload( + cell = buildJettonToTonPayloadV2( + amount = amountUnits, + router = AddrStd(response.routerAddress), + ptonWallet = AddrStd(response.askJettonWallet), + refundAddress = AddrStd(request.addressFrom), + minOut = request.minimumAskUnits, + forwardGas = response.gasParams.forwardGas, + queryId = request.queryId, + refFee = REF_FEE_BPS, + referralAddress = REF_ADDRESS_TON?.let(::AddrStd), + ), + gasBudget = response.gasParams.gasBudget, + ) + else -> error("Unsupported dex version: ${request.dexVersion}") + } + } + + private fun nativeTonSwapPayload(request: TonSwapPayloadRequest): TonSwapPayload { + val response = request.response + return when (request.dexVersion) { + 1 -> TonSwapPayload( + cell = buildStonfiSwapTonToJettonTransferV1( + amount = request.amountIn.movePointRight(request.tokenIn.decimals).toBigInteger(), + routerAddress = AddrStd(response.routerAddress), + routerJettonWallet = AddrStd( + response.askJettonWallet.takeUnless { it.isNullOrBlank() } + ?: error("STON.fi v1: missing ask jetton wallet") + ), + receiver = AddrStd(request.receiverOwnerAddress), + minOut = request.minimumAskUnits, + referralAddress = REF_ADDRESS_TON?.let(::AddrStd), + forwardTonAmount = response.gasParams.forwardGas, + queryId = request.queryId, + ), + gasBudget = response.offerUnits + BigInteger("185000000"), + ) + 2 -> TonSwapPayload( + cell = buildStonfiSwapTonToJettonPayloadV2( + tonAmount = response.offerUnits, + tokenWallet = AddrStd(response.askJettonWallet), + refundAddress = AddrStd(request.addressFrom), + minOut = request.minimumAskUnits, + receiver = AddrStd(request.receiverOwnerAddress), + refFee = REF_FEE_BPS, + fwdGas = response.gasParams.forwardGas, + referralAddress = REF_ADDRESS_TON?.let(::AddrStd), + ), + gasBudget = response.gasParams.gasBudget, + ) + else -> error("Unsupported dex version: ${request.dexVersion}") + } + } + + private fun simulationRequest( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + slippage: BigDecimal, + direction: SwapAmountDirection, + poolAddress: String? = null, + ) = SimulationRequest( + offerAddress = getTokenAddress(tokenIn), + askAddress = getTokenAddress(tokenOut), + units = amount.movePointRight( + if (direction == SwapAmountDirection.In) tokenIn.decimals else tokenOut.decimals + ).toBigInteger().toString(), + slippageTolerance = slippage, + poolAddress = poolAddress, + referralAddress = REF_ADDRESS_TON, + referralFeeBps = REF_ADDRESS_TON?.let { REF_FEE_BPS }, + direction = direction, + ) + private suspend fun simulateSwapWithFallback( - offerAddress: String, - askAddress: String, - units: String, - slippageTolerance: BigDecimal, - poolAddress: String?, - referralAddress: String?, - referralFeeBps: Int?, - preferredVersions: List + request: SimulationRequest, + preferredVersions: List, ): SimulationResult { val errors = mutableListOf() preferredVersions.forEachIndexed { index, dexVersion -> - val poolForAttempt = if (index == 0) poolAddress else null + val attempt = request.copy(poolAddress = if (index == 0) request.poolAddress else null) val result = runCatching { - stonFiRepository.simulateSwap( - offerAddress = offerAddress, - askAddress = askAddress, - units = units, - slippageTolerance = slippageTolerance, - poolAddress = poolForAttempt, - referralAddress = referralAddress, - referralFeeBps = referralFeeBps, - dexVersion = dexVersion - ) + simulateSwap(attempt, dexVersion) }.getOrElse { errors.add(it) null @@ -352,6 +483,33 @@ class StonFiProvider( throw cause ?: IllegalStateException("Failed to simulate swap on STON.fi") } + private suspend fun simulateSwap( + request: SimulationRequest, + dexVersion: Int, + ): SimulateSwap = when (request.direction) { + SwapAmountDirection.In -> stonFiRepository.simulateSwap( + request.offerAddress, + request.askAddress, + request.units, + request.slippageTolerance, + request.poolAddress, + request.referralAddress, + request.referralFeeBps, + dexVersion, + ) + + SwapAmountDirection.Out -> stonFiRepository.reverseSimulateSwap( + request.offerAddress, + request.askAddress, + request.units, + request.slippageTolerance, + request.poolAddress, + request.referralAddress, + request.referralFeeBps, + dexVersion, + ) + } + private fun SimulateSwap.hasPositiveOutput(): Boolean { val askValue = parsePositiveBigDecimal(askUnits) val minAskValue = parseNonNegativeBigDecimal(minAskUnits) @@ -359,16 +517,53 @@ class StonFiProvider( } private fun parsePositiveBigDecimal(value: String): BigDecimal? = - runCatching { BigDecimal(value) }.getOrNull()?.takeIf { it.signum() > 0 } + tryOrNull { BigDecimal(value) }?.takeIf { it.signum() > 0 } private fun parseNonNegativeBigDecimal(value: String): BigDecimal? = - runCatching { BigDecimal(value) }.getOrNull()?.takeIf { it.signum() >= 0 } + tryOrNull { BigDecimal(value) }?.takeIf { it.signum() >= 0 } private data class SimulationResult( val swap: SimulateSwap, val dexVersion: Int ) + private data class SimulationRequest( + val offerAddress: String, + val askAddress: String, + val units: String, + val slippageTolerance: BigDecimal, + val poolAddress: String?, + val referralAddress: String?, + val referralFeeBps: Int?, + val direction: SwapAmountDirection, + ) + + private data class TonSwapPayloadRequest( + val tokenIn: Token, + val amountIn: BigDecimal, + val response: SimulateSwap, + val dexVersion: Int, + val addressFrom: String, + val receiverOwnerAddress: String, + val routerInfo: RouterInfo, + val queryId: Long, + val minimumAskUnits: BigInteger, + ) + + private data class TonSwapPayload( + val cell: Cell, + val gasBudget: BigInteger, + ) + + private data class FinalQuoteAmounts( + val tokenIn: Token, + val tokenOut: Token, + val amountIn: BigDecimal, + val amountOut: BigDecimal, + val minimumAmountOut: BigDecimal, + val minimumAskUnits: BigInteger, + ) + private fun getTokenAddress(token: Token): String { return when (val tokenType = token.type) { TokenType.Native -> TON_NATIVE_ADDRESS @@ -389,3 +584,13 @@ class SwapFinalQuoteTon( override val fields: List, override val cautions: List = listOf() ) : ISwapFinalQuote + +internal fun stonFiMinimumAskUnits( + direction: SwapAmountDirection, + amount: BigDecimal, + tokenOutDecimals: Int, + simulatedMinimum: String, +): BigInteger = when (direction) { + SwapAmountDirection.In -> BigInteger(simulatedMinimum) + SwapAmountDirection.Out -> amount.movePointRight(tokenOutDecimals).toBigInteger() +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/SwapHelper.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/SwapHelper.kt index 41ae7ad1f80..794dfdf5c33 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/SwapHelper.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/SwapHelper.kt @@ -1,6 +1,8 @@ package cash.p.terminal.modules.multiswap.providers import cash.p.terminal.core.App +import cash.p.terminal.R +import cash.p.terminal.core.HSCaution import cash.p.terminal.core.adapters.BitcoinAdapter import cash.p.terminal.core.adapters.BitcoinCashAdapter import cash.p.terminal.core.adapters.LitecoinAdapter @@ -12,6 +14,7 @@ import cash.p.terminal.entities.transactionrecords.TransactionRecordType import cash.p.terminal.modules.multiswap.action.ActionApprove import cash.p.terminal.modules.multiswap.action.ActionRevoke import cash.p.terminal.modules.multiswap.action.ISwapProviderAction +import cash.p.terminal.strings.helpers.TranslatableString import cash.p.terminal.wallet.IReceiveAdapter import cash.p.terminal.wallet.NoActiveAccount import cash.p.terminal.wallet.Token @@ -141,3 +144,20 @@ object SwapHelper { } } + +class InsufficientAllowanceCaution : HSCaution( + TranslatableString.ResString(R.string.swap_reapprove_required), + Type.Error, +) + +internal fun requiredInput(amountIn: BigDecimal, amountInMax: BigDecimal?): BigDecimal = + amountInMax ?: amountIn + +internal fun insufficientAllowanceCaution( + allowance: BigDecimal?, + requiredInput: BigDecimal, +): HSCaution? = if (allowance != null && allowance < requiredInput) { + InsufficientAllowanceCaution() +} else { + null +} diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/UniswapV3Provider.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/UniswapV3Provider.kt index 5f69a18fa6e..69504b66fb6 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/providers/UniswapV3Provider.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/providers/UniswapV3Provider.kt @@ -10,7 +10,7 @@ object UniswapV3Provider : BaseUniswapV3Provider(DexType.Uniswap) { override val title = "Uniswap V3" override val icon = R.drawable.uniswap_v3 - override suspend fun supports(token: Token) = when (token) { + override suspend fun supports(token: Token) = when (token.blockchainType) { BlockchainType.Ethereum, BlockchainType.ArbitrumOne, // BlockchainType.Optimism, diff --git a/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreApiDtos.kt b/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreApiDtos.kt index ecc24f5e861..ee9d4b201ee 100644 --- a/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreApiDtos.kt +++ b/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreApiDtos.kt @@ -129,6 +129,22 @@ object PayCoreAmountType { const val RUB = "Rub" } +internal fun validatePayCoreExactOutTarget( + requestedAmount: BigDecimal, + amountType: String, + amountCrypto: BigDecimal, + fullAmountRub: BigDecimal, +) { + val actualAmount = when (amountType) { + PayCoreAmountType.CRYPTO -> amountCrypto + PayCoreAmountType.RUB -> fullAmountRub + else -> error("Unsupported PayCore amount type") + } + check(actualAmount.compareTo(requestedAmount) == 0) { + "PayCore exact-out target mismatch" + } +} + @Serializable data class PayCorePaymentCalculationRequest( @Serializable(with = PayCoreBigDecimalSerializer::class) val amount: BigDecimal, diff --git a/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreProvider.kt b/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreProvider.kt index 7546bc1b51b..9318bc11c49 100644 --- a/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreProvider.kt +++ b/app/src/main/java/cash/p/terminal/modules/paycore/PayCoreProvider.kt @@ -8,9 +8,12 @@ import cash.p.terminal.core.tryOrNull import cash.p.terminal.entities.SwapProviderTransaction import cash.p.terminal.modules.multiswap.ISwapFinalQuote import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.multiswap.SwapAmountOutOfRange import cash.p.terminal.modules.multiswap.action.ISwapProviderAction import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.modules.multiswap.providers.IExactOutSwapProvider import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionResult import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionSettings @@ -47,7 +50,7 @@ class PayCoreProvider( private val adapterManager: IAdapterManager, private val swapProviderTransactionsStorage: SwapProviderTransactionsStorage, private val dispatcherProvider: DispatcherProvider, -) : IMultiSwapProvider { +) : IMultiSwapProvider, IExactOutSwapProvider { private var swapProviderTransaction: SwapProviderTransaction? = null @@ -55,6 +58,7 @@ class PayCoreProvider( override val title = "PayCore" override val icon = R.drawable.ic_paycore override val mevProtectionAvailable = false + override val exactOutAccuracy = SwapAmountAccuracy.Exact override suspend fun supports(tokenFrom: Token, tokenTo: Token): Boolean { if (!featureToggle.isEnabled()) return false @@ -79,6 +83,36 @@ class PayCoreProvider( tokenOut: Token, amountIn: BigDecimal, settings: Map + ): ISwapQuote = fetchQuote( + tokenIn, + tokenOut, + amountIn, + settings, + SwapAmountDirection.In, + ) + + override suspend fun supportsExactOut(tokenIn: Token, tokenOut: Token): Boolean = + supports(tokenIn, tokenOut) + + override suspend fun fetchQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + settings: Map, + ): ISwapQuote = fetchQuote( + tokenIn, + tokenOut, + amountOut, + settings, + SwapAmountDirection.Out, + ) + + private suspend fun fetchQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + settings: Map, + direction: SwapAmountDirection, ): ISwapQuote { val networkType = resolveNetworkType(tokenIn, tokenOut) val ticker = requireTicker(tokenIn, tokenOut) @@ -88,11 +122,12 @@ class PayCoreProvider( throw SwapAmountOutOfRange() } - val amountOut = estimateAmountOut( + val (amountIn, amountOut) = estimateAmounts( tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, + amount = amount, rateResponse = rateResponse, + direction = direction, ) validateRateLimits( tokenIn = tokenIn, @@ -138,6 +173,38 @@ class PayCoreProvider( swapSettings: Map, sendTransactionSettings: SendTransactionSettings?, swapQuote: ISwapQuote + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountIn, + swapSettings, + swapQuote, + SwapAmountDirection.In, + ) + + override suspend fun fetchFinalQuoteExactOut( + tokenIn: Token, + tokenOut: Token, + amountOut: BigDecimal, + swapSettings: Map, + sendTransactionSettings: SendTransactionSettings?, + swapQuote: ISwapQuote, + ): ISwapFinalQuote = fetchFinalQuote( + tokenIn, + tokenOut, + amountOut, + swapSettings, + swapQuote, + SwapAmountDirection.Out, + ) + + private suspend fun fetchFinalQuote( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + swapSettings: Map, + swapQuote: ISwapQuote, + direction: SwapAmountDirection, ): ISwapFinalQuote { require(isPayout(tokenIn, tokenOut)) { "PayCore final quote is only used for Crypto -> RUB flow" @@ -146,9 +213,18 @@ class PayCoreProvider( val payoutCalculation = calculatePayout( tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, + amount = amount, settings = swapSettings, + direction = direction, ) + if (direction == SwapAmountDirection.Out) { + validatePayCoreExactOutTarget( + requestedAmount = amount, + amountType = PayCoreAmountType.RUB, + amountCrypto = payoutCalculation.amountCrypto, + fullAmountRub = payoutCalculation.fullAmountRub, + ) + } val payout = createPayout(payoutCalculation, networkType) val finalAmountIn = payoutCalculation.amountCrypto @@ -203,6 +279,24 @@ class PayCoreProvider( } } + private fun estimateAmounts( + tokenIn: Token, + tokenOut: Token, + amount: BigDecimal, + rateResponse: PayCoreRateResponse, + direction: SwapAmountDirection, + ): Pair { + if (direction == SwapAmountDirection.In) { + return amount to estimateAmountOut(tokenIn, tokenOut, amount, rateResponse) + } + val amountIn = if (PayCoreAssets.isRub(tokenIn)) { + amount.multiply(rateResponse.buy).setScale(tokenIn.decimals, RoundingMode.UP) + } else { + amount.divide(rateResponse.sell, tokenIn.decimals, RoundingMode.UP) + }.stripTrailingZeros() + return amountIn to amount + } + private fun estimateAmountOut( tokenIn: Token, tokenOut: Token, @@ -257,15 +351,20 @@ class PayCoreProvider( private suspend fun calculatePayout( tokenIn: Token, tokenOut: Token, - amountIn: BigDecimal, + amount: BigDecimal, settings: Map, + direction: SwapAmountDirection, ): PayCorePayoutCalculationResponse { val ticker = requireTicker(tokenIn, tokenOut) val networkType = resolveNetworkType(tokenIn, tokenOut) val bank = requireSelectedBank(settings, networkType) val request = PayCorePayoutCalculationRequest( - amount = amountIn, - amountType = PayCoreAmountType.CRYPTO, + amount = amount, + amountType = if (direction == SwapAmountDirection.In) { + PayCoreAmountType.CRYPTO + } else { + PayCoreAmountType.RUB + }, bankId = bank.id, ticker = ticker, ) diff --git a/app/src/main/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModel.kt b/app/src/main/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModel.kt index f40aaa207de..fa9daf1a0bc 100644 --- a/app/src/main/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import cash.p.terminal.R import cash.p.terminal.core.storage.SwapProviderTransactionsStorage import cash.p.terminal.entities.SwapProviderTransaction +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.paycore.PayCoreApiService import cash.p.terminal.modules.paycore.PayCoreAmountType import cash.p.terminal.modules.paycore.PayCoreTicker @@ -15,6 +16,7 @@ import cash.p.terminal.modules.paycore.PayCorePaymentCalculationRequest import cash.p.terminal.modules.paycore.PayCorePaymentCreateRequest import cash.p.terminal.modules.paycore.PayCoreWalletApprovalService import cash.p.terminal.modules.paycore.payCoreUserMessage +import cash.p.terminal.modules.paycore.validatePayCoreExactOutTarget import cash.p.terminal.network.changenow.domain.entity.TransactionStatusEnum import cash.p.terminal.network.swaprepository.parseIsoTimestamp import cash.p.terminal.network.swaprepository.SwapProvider @@ -34,6 +36,8 @@ data class PayCorePaymentParams( val blockchainTypeIn: String, val blockchainTypeOut: String, val addressOut: String, + val direction: SwapAmountDirection = SwapAmountDirection.In, + val requestedAmountOut: BigDecimal? = null, ) class PayCorePaymentViewModel( @@ -144,14 +148,23 @@ class PayCorePaymentViewModel( walletAddress = params.addressOut, networkType = params.networkType, ) + val (amount, amountType) = calculationAmount() val response = apiService.calculatePayment( request = PayCorePaymentCalculationRequest( - amount = params.amountIn, - amountType = PayCoreAmountType.RUB, + amount = amount, + amountType = amountType, ticker = params.networkType, ), networkType = params.networkType, ) + if (params.direction == SwapAmountDirection.Out) { + validatePayCoreExactOutTarget( + requestedAmount = amount, + amountType = amountType, + amountCrypto = response.amountCrypto, + fullAmountRub = response.fullAmountRub, + ) + } calculationCreatedAtMillis = System.currentTimeMillis() uiState = uiState.copy( loading = createPaymentAfterCalculation, @@ -178,6 +191,13 @@ class PayCorePaymentViewModel( } } + private fun calculationAmount(): Pair = + when (params.direction) { + SwapAmountDirection.In -> params.amountIn to PayCoreAmountType.RUB + SwapAmountDirection.Out -> + checkNotNull(params.requestedAmountOut) to PayCoreAmountType.CRYPTO + } + private fun hasFreshCalculation(): Boolean { val createdAt = calculationCreatedAtMillis ?: return false return System.currentTimeMillis() - createdAt < PAYMENT_CALCULATION_REFRESH_MS diff --git a/app/src/test/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCaseTest.kt b/app/src/test/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCaseTest.kt index 88c8b37bf6c..ee11502e25e 100644 --- a/app/src/test/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCaseTest.kt +++ b/app/src/test/java/cash/p/terminal/core/usecase/FetchSwapQuotesUseCaseTest.kt @@ -1,15 +1,26 @@ package cash.p.terminal.core.usecase +import cash.p.terminal.core.HSCaution import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.SwapAmountDirection +import cash.p.terminal.modules.multiswap.SwapExecutionMode +import cash.p.terminal.modules.multiswap.SwapProviderQuote +import cash.p.terminal.modules.multiswap.providers.AllBridgeProvider +import cash.p.terminal.modules.multiswap.providers.IExactOutSwapProvider import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider import cash.p.terminal.wallet.Token import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import java.math.BigDecimal @@ -17,7 +28,7 @@ import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) class FetchSwapQuotesUseCaseTest { - private val useCase = FetchSwapQuotesUseCase() + private val useCase = FetchSwapQuotesUseCase(mockk(relaxed = true)) private val tokenIn = mockk() private val tokenOut = mockk() private val amountIn = BigDecimal("1.0") @@ -26,12 +37,15 @@ class FetchSwapQuotesUseCaseTest { id: String, supports: Boolean = true, amountOut: BigDecimal = BigDecimal.ONE, - ): IMultiSwapProvider = mockk(relaxed = true) { - every { this@mockk.id } returns id - coEvery { supports(tokenIn, tokenOut) } returns supports - coEvery { fetchQuote(tokenIn, tokenOut, amountIn, any()) } returns mockk { + ): IMultiSwapProvider { + val quote = mockk { every { this@mockk.amountOut } returns amountOut } + return mockk(relaxed = true) { + every { this@mockk.id } returns id + coEvery { supports(tokenIn, tokenOut) } returns supports + coEvery { fetchQuote(tokenIn, tokenOut, amountIn, any()) } returns quote + } } @Test @@ -40,7 +54,13 @@ class FetchSwapQuotesUseCaseTest { val large = mockProvider("large", amountOut = BigDecimal("10")) val medium = mockProvider("medium", amountOut = BigDecimal("5")) - val result = useCase(listOf(small, large, medium), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(small, large, medium), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertEquals("large", result[0].provider.id) assertEquals("medium", result[1].provider.id) @@ -52,7 +72,13 @@ class FetchSwapQuotesUseCaseTest { val supported = mockProvider("ok", supports = true) val unsupported = mockProvider("no", supports = false) - val result = useCase(listOf(supported, unsupported), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(supported, unsupported), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertEquals(1, result.size) assertEquals("ok", result[0].provider.id) @@ -62,7 +88,13 @@ class FetchSwapQuotesUseCaseTest { fun allUnsupported_emptyList() = runTest { val unsupported = mockProvider("no", supports = false) - val result = useCase(listOf(unsupported), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(unsupported), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertTrue(result.isEmpty()) } @@ -75,7 +107,13 @@ class FetchSwapQuotesUseCaseTest { } val ok = mockProvider("ok") - val result = useCase(listOf(failing, ok), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(failing, ok), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertEquals(1, result.size) assertEquals("ok", result[0].provider.id) @@ -90,7 +128,13 @@ class FetchSwapQuotesUseCaseTest { } val ok = mockProvider("ok") - val result = useCase(listOf(failing, ok), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(failing, ok), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertEquals(1, result.size) assertEquals("ok", result[0].provider.id) @@ -107,7 +151,13 @@ class FetchSwapQuotesUseCaseTest { } val fast = mockProvider("fast") - val result = useCase(listOf(slow, fast), tokenIn, tokenOut, amountIn) + val result = useCase( + listOf(slow, fast), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) assertEquals(1, result.size) assertEquals("fast", result[0].provider.id) @@ -126,7 +176,8 @@ class FetchSwapQuotesUseCaseTest { providers = listOf(ok, failing), tokenIn = tokenIn, tokenOut = tokenOut, - amountIn = amountIn, + amount = amountIn, + direction = SwapAmountDirection.In, onProviderError = { provider, e -> errors.add(provider.id to e) }, ) @@ -139,7 +190,162 @@ class FetchSwapQuotesUseCaseTest { @Test fun emptyProvidersList_emptyResult() = runTest { - val result = useCase(emptyList(), tokenIn, tokenOut, amountIn) + val result = useCase( + emptyList(), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.In, + ) + assertTrue(result.isEmpty()) + } + + @Test + fun exactOut_nativeProvider_preservesQuoteAndExecutionMetadata() = runTest { + val search = mockk(relaxed = true) + val useCase = FetchSwapQuotesUseCase(search) + val sourceQuote = mockk(relaxed = true) { + every { amountIn } returns BigDecimal("2") + every { amountOut } returns BigDecimal("1") + } + val provider = mockk(relaxed = true) { + every { id } returns "native" + every { exactOutAccuracy } returns SwapAmountAccuracy.AtLeast + coEvery { supportsExactOut(tokenIn, tokenOut) } returns true + coEvery { fetchQuoteExactOut(tokenIn, tokenOut, amountIn, any()) } returns sourceQuote + } + + val result = useCase( + listOf(provider), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.Out, + ).single() + + assertSame(sourceQuote, result.swapQuote) + assertEquals(SwapExecutionMode.NativeExactOut, result.executionMode) + assertEquals(SwapAmountAccuracy.AtLeast, result.amountOutAccuracy) + coVerify(exactly = 0) { search.search(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun exactOut_nativeProviderRejectsPair_doesNotFallBackToIterativeSearch() = runTest { + val search = mockk(relaxed = true) + val provider = mockk(relaxed = true) { + every { id } returns "native" + coEvery { supportsExactOut(tokenIn, tokenOut) } returns false + } + + val result = FetchSwapQuotesUseCase(search)( + listOf(provider), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.Out, + ) + assertTrue(result.isEmpty()) + coVerify(exactly = 0) { search.search(any(), any(), any(), any(), any(), any()) } } + + @Test + fun exactOut_withoutNativeSupport_usesIterativeSearch() = runTest { + val provider = mockProvider("iterative") + val estimated = estimatedQuote(provider) + val search = mockSearch(provider, estimated) + val result = FetchSwapQuotesUseCase(search)( + listOf(provider), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.Out, + ) + + assertEquals(listOf(estimated), result) + coVerify(exactly = 1) { + search.search(provider, tokenIn, tokenOut, amountIn, any(), any()) + } + } + + @Test + fun exactOut_allBridgeProvider_usesIterativeSearch() = runTest { + mockkObject(AllBridgeProvider) + try { + coEvery { AllBridgeProvider.supports(tokenIn, tokenOut) } returns true + val estimated = estimatedQuote(AllBridgeProvider) + val search = mockSearch(AllBridgeProvider, estimated) + + val result = FetchSwapQuotesUseCase(search)( + listOf(AllBridgeProvider), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.Out, + ) + + assertEquals(listOf(estimated), result) + coVerify(exactly = 1) { + search.search(AllBridgeProvider, tokenIn, tokenOut, amountIn, any(), any()) + } + } finally { + unmockkObject(AllBridgeProvider) + } + } + + @Test + fun exactOut_iterativeSearchFails_preservesOtherProviderQuote() = runTest { + val failing = mockProvider("failing") + val working = mockProvider("working") + val estimated = estimatedQuote(working) + val search = mockk { + coEvery { + search(failing, tokenIn, tokenOut, amountIn, any(), any()) + } returns null + coEvery { + search(working, tokenIn, tokenOut, amountIn, any(), any()) + } returns estimated + } + + val result = FetchSwapQuotesUseCase(search)( + listOf(failing, working), + tokenIn, + tokenOut, + amountIn, + SwapAmountDirection.Out, + ) + + assertEquals(listOf(estimated), result) + coVerify(exactly = 1) { + search.search(failing, tokenIn, tokenOut, amountIn, any(), any()) + search.search(working, tokenIn, tokenOut, amountIn, any(), any()) + } + } + + @Test + fun swapProviderQuote_providerCautions_areExposed() { + val caution = mockk() + val swapQuote = mockk(relaxed = true) { + every { cautions } returns listOf(caution) + } + + assertEquals(listOf(caution), SwapProviderQuote(mockProvider("provider"), swapQuote).cautions) + } + + private fun estimatedQuote(provider: IMultiSwapProvider) = SwapProviderQuote( + provider, + mockk(relaxed = true), + amountOutAccuracy = SwapAmountAccuracy.Estimated, + ) + + private fun mockSearch( + provider: IMultiSwapProvider, + result: SwapProviderQuote, + ) = mockk { + coEvery { + search(provider, tokenIn, tokenOut, amountIn, any(), any()) + } returns result + } + + private interface ExactOutProvider : IMultiSwapProvider, IExactOutSwapProvider } diff --git a/app/src/test/java/cash/p/terminal/core/usecase/IterativeExactOutSearchTest.kt b/app/src/test/java/cash/p/terminal/core/usecase/IterativeExactOutSearchTest.kt new file mode 100644 index 00000000000..94ebec96b81 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/core/usecase/IterativeExactOutSearchTest.kt @@ -0,0 +1,386 @@ +package cash.p.terminal.core.usecase + +import cash.p.terminal.core.TestDispatcherProvider +import cash.p.terminal.modules.multiswap.AssetFiatRateService +import cash.p.terminal.modules.multiswap.ISwapQuote +import cash.p.terminal.modules.multiswap.SwapAmountAccuracy +import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.wallet.Token +import io.horizontalsystems.core.CurrencyManager +import io.horizontalsystems.core.entities.Currency +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalCoroutinesApi::class) +class IterativeExactOutSearchTest { + + private val tokenIn = mockk { + every { decimals } returns 8 + } + private val tokenOut = mockk() + private val currency = Currency("USD", "$", 2, 0) + private val currencyManager = mockk { + every { baseCurrency } returns currency + } + private val rateService = mockk() + private val dispatcher = UnconfinedTestDispatcher() + private val dispatcherProvider = TestDispatcherProvider( + dispatcher, + CoroutineScope(dispatcher), + ) + + @Test + fun search_proportionalQuote_convergesWithinTwoRequests() = runTest { + mockRates(BigDecimal.ONE, BigDecimal.ONE) + var requests = 0 + val provider = provider { input -> + requests++ + input + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("100"), + emptyMap(), + ) + + assertEquals(SwapAmountAccuracy.Estimated, result?.amountOutAccuracy) + assertTrue(requests <= 2) + assertTrue(requireNotNull(result).amountOut >= BigDecimal("100")) + assertTrue(result.amountOut <= BigDecimal("100.5")) + } + + @Test + fun search_ammWithPriceImpact_convergesWithinBudget() = runTest { + mockRates(BigDecimal.ONE, BigDecimal.ONE) + var requests = 0 + val provider = provider { input -> + requests++ + input.multiply(BigDecimal("1000")) + .divide(BigDecimal("1000").add(input), 16, RoundingMode.HALF_UP) + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("100"), + emptyMap(), + ) + + assertTrue(requireNotNull(result).amountOut >= BigDecimal("100")) + assertTrue(result.amountOut <= BigDecimal("100.5")) + assertTrue(requests <= 4) + } + + @Test + fun search_spreadAndFixedFee_convergesWithinBudget() = runTest { + mockRates(BigDecimal.ONE, BigDecimal.ONE) + var requests = 0 + val provider = provider { input -> + requests++ + input.multiply(BigDecimal("0.9")).subtract(BigDecimal("5")) + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("100"), + emptyMap(), + ) + + assertTrue(requireNotNull(result).amountOut >= BigDecimal("100")) + assertTrue(result.amountOut <= BigDecimal("100.5")) + assertTrue(requests <= 4) + } + + @Test + fun search_missingPrices_usesExploratoryRequest() = runTest { + mockRates(null, BigDecimal.ZERO) + var requests = 0 + val provider = provider { input -> + requests++ + input + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("10"), + emptyMap(), + ) + + assertEquals(0, result?.amountOut?.compareTo(BigDecimal("10"))) + assertEquals(1, requests) + } + + @Test + fun search_nonTerminatingPriceRatio_doesNotThrow() = runTest { + mockRates(BigDecimal.ONE, BigDecimal("3")) + val provider = provider { input -> input.multiply(BigDecimal("3")) } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("100"), + emptyMap(), + ) + + assertTrue(requireNotNull(result).amountOut >= BigDecimal("100")) + } + + @Test + fun search_nonPositiveOutput_stopsWithoutExtraRequests() = runTest { + mockRates(null, null) + var requests = 0 + val provider = provider { + requests++ + BigDecimal.ZERO + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal.TEN, + emptyMap(), + ) + + assertNull(result) + assertEquals(1, requests) + } + + @Test + fun search_providerThrows_returnsNullAndReportsError() = runTest { + mockRates(null, null) + val failure = IllegalStateException("quote unavailable") + val provider = provider { throw failure } + var reportedError: Throwable? = null + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal.TEN, + emptyMap(), + ) { _, error -> + reportedError = error + } + + assertNull(result) + assertSame(failure, reportedError) + } + + @Test + fun search_unbracketedModel_returnsNullWithinBudget() = runTest { + mockRates(BigDecimal.ONE, BigDecimal.ONE) + var requests = 0 + val provider = provider { + requests++ + BigDecimal("200") + } + + val result = search().search( + provider, + tokenIn, + tokenOut, + BigDecimal("100"), + emptyMap(), + ) + + assertNull(result) + assertTrue(requests <= 4) + } + + @Test + fun search_sameKey_reusesCachedQuoteWithoutRefreshingCreatedAt() = runTest { + mockRates(BigDecimal.ONE, BigDecimal.ONE) + var now = 1_000L + var requests = 0 + val provider = provider { input -> + requests++ + input + } + val search = search { now } + + val first = search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + now += 10_000 + val cached = search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + + assertSame(first, cached) + assertEquals(1_000L, cached?.createdAt) + assertTrue(requests <= 2) + + val requestsBeforeExpiry = requests + now = 21_001L + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + assertTrue(requests > requestsBeforeExpiry) + } + + @Test + fun search_concurrentSameKey_runsSingleNetworkScenario() = runTest { + mockRates(null, null) + val gate = CompletableDeferred() + var requests = 0 + val provider = provider { input -> + requests++ + gate.await() + input + } + val search = search() + + val first = async { + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + } + val second = async { + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + } + yield() + + assertEquals(1, requests) + gate.complete(Unit) + assertSame(first.await(), second.await()) + assertEquals(1, requests) + } + + @Test + fun search_firstWaiterCancelled_secondWaiterStillReceivesSharedResult() = runTest { + mockRates(null, null) + val started = CompletableDeferred() + val gate = CompletableDeferred() + var requests = 0 + val provider = provider { input -> + requests++ + started.complete(Unit) + gate.await() + input + } + val search = search() + + val first = async { + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + } + started.await() + val second = async { + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + } + yield() + + first.cancelAndJoin() + gate.complete(Unit) + + assertNotNull(second.await()) + assertEquals(1, requests) + } + + @Test + fun invalidate_inFlightSearch_replacementUsesNewNetworkScenario() = runTest { + mockRates(null, null) + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + var requests = 0 + val provider = provider { input -> + requests++ + if (requests == 1) { + firstStarted.complete(Unit) + firstGate.await() + } + input + } + val search = search() + val stale = async { + search.search(provider, tokenIn, tokenOut, BigDecimal.TEN, emptyMap()) + } + firstStarted.await() + + search.invalidate() + val replacement = search.search( + provider, + tokenIn, + tokenOut, + BigDecimal.TEN, + emptyMap(), + ) + + assertNotNull(replacement) + assertEquals(2, requests) + try { + stale.await() + fail("Stale search must be cancelled by invalidation") + } catch (_: CancellationException) { + Unit + } + } + + @Test + fun invalidate_cachedQuote_forcesNewSearch() = runTest { + mockRates(null, null) + var requests = 0 + val provider = provider { input -> + requests++ + input + } + val search = search() + + search.search(provider, tokenIn, tokenOut, BigDecimal.ONE, emptyMap()) + search.invalidate() + search.search(provider, tokenIn, tokenOut, BigDecimal.ONE, emptyMap()) + + assertEquals(2, requests) + } + + private fun search(currentTimeMillis: () -> Long = System::currentTimeMillis) = + IterativeExactOutSearch( + rateService, + currencyManager, + dispatcherProvider, + ).apply { + this.currentTimeMillis = currentTimeMillis + } + + private fun mockRates(priceIn: BigDecimal?, priceOut: BigDecimal?) { + coEvery { rateService.rate(tokenIn, currency) } returns priceIn + coEvery { rateService.rate(tokenOut, currency) } returns priceOut + } + + private fun provider(output: suspend (BigDecimal) -> BigDecimal): IMultiSwapProvider = + mockk(relaxed = true) { + every { id } returns "provider" + coEvery { fetchQuote(tokenIn, tokenOut, any(), any()) } coAnswers { + val amountIn = thirdArg() + quote(amountIn, output(amountIn)) + } + } + + private fun quote(amountIn: BigDecimal, amountOut: BigDecimal): ISwapQuote = + mockk(relaxed = true) { + every { this@mockk.amountIn } returns amountIn + every { this@mockk.amountOut } returns amountOut + every { tokenIn } returns this@IterativeExactOutSearchTest.tokenIn + every { tokenOut } returns this@IterativeExactOutSearchTest.tokenOut + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/FiatServiceTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/FiatServiceTest.kt index 4d29eee1b92..3ca72c4d6c8 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/FiatServiceTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/FiatServiceTest.kt @@ -7,6 +7,7 @@ import io.mockk.mockk import io.mockk.unmockkAll import java.math.BigDecimal import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher @@ -68,6 +69,73 @@ class FiatServiceTest { assertNull(service.stateFlow.value.amount) } + @Test + fun setInputAmount_rateChanges_preservesTokenAmount() = runTest { + val rateFlow = MutableStateFlow(BigDecimal("0.25")) + every { assetFiatRateService.rateFlow("swap", token, currency) } returns rateFlow + val service = createService(StandardTestDispatcher(testScheduler)) + + service.setCurrency(currency) + service.setToken(token) + advanceUntilIdle() + service.setInputAmount(BigDecimal("4")) + rateFlow.value = BigDecimal("0.50") + advanceUntilIdle() + + assertBigDecimalEquals(BigDecimal("4"), service.stateFlow.value.amount) + assertBigDecimalEquals(BigDecimal("2"), service.stateFlow.value.fiatAmount) + } + + @Test + fun setFiatAmount_rateChanges_preservesFiatAmount() = runTest { + val rateFlow = MutableStateFlow(BigDecimal("0.25")) + every { assetFiatRateService.rateFlow("swap", token, currency) } returns rateFlow + val service = createService(StandardTestDispatcher(testScheduler)) + + service.setCurrency(currency) + service.setToken(token) + advanceUntilIdle() + service.setFiatAmount(BigDecimal.ONE) + rateFlow.value = BigDecimal("0.20") + advanceUntilIdle() + + assertBigDecimalEquals(BigDecimal("5"), service.stateFlow.value.amount) + assertBigDecimalEquals(BigDecimal.ONE, service.stateFlow.value.fiatAmount) + } + + @Test + fun setAmount_fiatInputActive_doesNotOverrideConvertedAmount() = runTest { + every { assetFiatRateService.rateFlow("swap", token, currency) } returns flowOf(BigDecimal("0.25")) + val service = createService(StandardTestDispatcher(testScheduler)) + + service.setCurrency(currency) + service.setToken(token) + advanceUntilIdle() + service.setFiatAmount(BigDecimal.ONE) + service.setAmount(BigDecimal("100")) + + assertBigDecimalEquals(BigDecimal("4"), service.stateFlow.value.amount) + assertBigDecimalEquals(BigDecimal.ONE, service.stateFlow.value.fiatAmount) + } + + @Test + fun useTokenAmount_afterFiatInput_usesConvertedTokenAsSource() = runTest { + val rateFlow = MutableStateFlow(BigDecimal("0.25")) + every { assetFiatRateService.rateFlow("swap", token, currency) } returns rateFlow + val service = createService(StandardTestDispatcher(testScheduler)) + + service.setCurrency(currency) + service.setToken(token) + advanceUntilIdle() + service.setFiatAmount(BigDecimal.ONE) + service.useTokenAmount() + rateFlow.value = BigDecimal("0.50") + advanceUntilIdle() + + assertBigDecimalEquals(BigDecimal("4"), service.stateFlow.value.amount) + assertBigDecimalEquals(BigDecimal("2"), service.stateFlow.value.fiatAmount) + } + private fun createService(dispatcher: TestDispatcher): FiatService { return FiatService( assetFiatRateService = assetFiatRateService, diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelLeg2Test.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelLeg2Test.kt index a72583c99e1..99208018b4c 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelLeg2Test.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelLeg2Test.kt @@ -150,20 +150,24 @@ class SwapConfirmViewModelLeg2Test { every { baseCurrency } returns Currency("USD", "$", 2, 0) } val vm = SwapConfirmViewModel( - swapProvider = provider, - swapQuote = swapQuote, - swapSettings = emptyMap(), + request = SwapConfirmRequest( + provider = provider, + quote = swapQuote, + settings = emptyMap(), + multiSwapLegInfo = legInfo, + ), currencyManager = currencyManager, - fiatServiceIn = FiatService(assetFiatRateService), - fiatServiceOut = FiatService(assetFiatRateService), - fiatServiceOutMin = FiatService(assetFiatRateService), + fiatServices = SwapConfirmFiatServices( + input = FiatService(assetFiatRateService), + output = FiatService(assetFiatRateService), + outputMinimum = FiatService(assetFiatRateService), + ), sendTransactionService = sendTransactionService, timerService = TimerService(), priceImpactService = PriceImpactService(), wallet = previewWallet, adapterManager = adapterManager, dispatcherProvider = dispatcherProvider, - multiSwapLegInfo = legInfo, ) viewModelStore.put("test-vm", vm) return vm diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelSaveTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelSaveTest.kt index 3f082bf152e..bf55c981f24 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelSaveTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapConfirmViewModelSaveTest.kt @@ -1,13 +1,17 @@ package cash.p.terminal.modules.multiswap import androidx.lifecycle.ViewModelStore +import cash.p.terminal.core.HSCaution import cash.p.terminal.core.ILocalStorage import cash.p.terminal.core.ServiceStateFlow import cash.p.terminal.core.TestDispatcherProvider +import cash.p.terminal.core.ethereum.CautionViewItem import cash.p.terminal.core.storage.PendingMultiSwapStorage import cash.p.terminal.core.storage.SwapProviderTransactionsStorage import cash.p.terminal.entities.SwapProviderTransaction +import cash.p.terminal.modules.multiswap.providers.IExactOutSwapProvider import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.modules.multiswap.providers.InsufficientAllowanceCaution import cash.p.terminal.modules.multiswap.providers.OffChainSwapProvider import cash.p.terminal.modules.multiswap.sendtransaction.ISendTransactionService import cash.p.terminal.modules.multiswap.sendtransaction.SendTransactionData @@ -44,7 +48,10 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.koin.core.context.startKoin @@ -155,7 +162,13 @@ class SwapConfirmViewModelSaveTest { return this } - private fun createViewModel(provider: IMultiSwapProvider): SwapConfirmViewModel { + private fun createViewModel( + provider: IMultiSwapProvider, + transactionState: SendTransactionServiceState = sendTransactionServiceState, + executionMode: SwapExecutionMode = SwapExecutionMode.ExactIn, + direction: SwapAmountDirection = SwapAmountDirection.In, + requestedAmountOut: BigDecimal? = null, + ): SwapConfirmViewModel { val sendTransactionService = mockk>(relaxed = true) { every { hasSettings() } returns false every { mevProtectionAvailable } returns false @@ -163,7 +176,7 @@ class SwapConfirmViewModelSaveTest { MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ).also { it.tryEmit(sendTransactionServiceState) }.asSharedFlow() + ).also { it.tryEmit(transactionState) }.asSharedFlow() ) every { sendTransactionSettingsFlow } returns MutableStateFlow(SendTransactionSettings.Common) } @@ -173,13 +186,20 @@ class SwapConfirmViewModelSaveTest { every { baseCurrency } returns Currency("USD", "$", 2, 0) } val vm = SwapConfirmViewModel( - swapProvider = provider, - swapQuote = swapQuote, - swapSettings = emptyMap(), + request = SwapConfirmRequest( + provider = provider, + quote = swapQuote, + settings = emptyMap(), + executionMode = executionMode, + direction = direction, + requestedAmountOut = requestedAmountOut, + ), currencyManager = currencyManager, - fiatServiceIn = FiatService(assetFiatRateService), - fiatServiceOut = FiatService(assetFiatRateService), - fiatServiceOutMin = FiatService(assetFiatRateService), + fiatServices = SwapConfirmFiatServices( + input = FiatService(assetFiatRateService), + output = FiatService(assetFiatRateService), + outputMinimum = FiatService(assetFiatRateService), + ), sendTransactionService = sendTransactionService, timerService = TimerService(), priceImpactService = PriceImpactService(), @@ -191,6 +211,120 @@ class SwapConfirmViewModelSaveTest { return vm } + @Test + fun fetchFinalQuote_nativeExactOut_dispatchesRequestedAmountAndSourceQuote() = runTest(dispatcher) { + val requestedAmountOut = BigDecimal("5") + val finalQuote = finalQuote(amountIn = BigDecimal("2"), amountOut = requestedAmountOut) + val provider = mockk(relaxed = true) { + every { mevProtectionAvailable } returns false + coEvery { + fetchFinalQuoteExactOut( + token, + token, + requestedAmountOut, + any(), + any(), + swapQuote, + ) + } returns finalQuote + } + + createViewModel( + provider = provider, + executionMode = SwapExecutionMode.NativeExactOut, + direction = SwapAmountDirection.Out, + requestedAmountOut = requestedAmountOut, + ) + advanceUntilIdle() + + coVerify(atLeast = 1) { + provider.fetchFinalQuoteExactOut( + token, + token, + requestedAmountOut, + any(), + any(), + swapQuote, + ) + } + coVerify(exactly = 0) { + provider.fetchFinalQuote(any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun createState_exactOutRequiredInputExceedsBalance_blocksQuote() = runTest(dispatcher) { + val amountInMax = BigDecimal("2") + val provider = mockk(relaxed = true) { + every { mevProtectionAvailable } returns false + coEvery { + fetchFinalQuoteExactOut(any(), any(), any(), any(), any(), any()) + } returns finalQuote( + amountIn = BigDecimal.ONE, + amountOut = BigDecimal.ONE, + amountInMax = amountInMax, + ) + } + val transactionState = sendTransactionServiceState.copy( + availableBalance = BigDecimal.ONE, + ) + + val viewModel = createViewModel( + provider = provider, + transactionState = transactionState, + executionMode = SwapExecutionMode.NativeExactOut, + direction = SwapAmountDirection.Out, + requestedAmountOut = BigDecimal.ONE, + ) + advanceUntilIdle() + + assertEquals(amountInMax, viewModel.uiState.amountInMax) + assertFalse(viewModel.uiState.validQuote) + assertTrue(viewModel.uiState.cautions.any { it.type == CautionViewItem.Type.Error }) + } + + @Test + fun createState_insufficientAllowance_marksReapprovalAndBlocksQuote() = runTest(dispatcher) { + val caution = InsufficientAllowanceCaution() + val provider = mockk(relaxed = true) { + every { mevProtectionAvailable } returns false + coEvery { + fetchFinalQuoteExactOut(any(), any(), any(), any(), any(), any()) + } returns finalQuote(cautions = listOf(caution)) + } + + val viewModel = createViewModel( + provider = provider, + executionMode = SwapExecutionMode.NativeExactOut, + direction = SwapAmountDirection.Out, + requestedAmountOut = BigDecimal.ONE, + ) + advanceUntilIdle() + + assertTrue(viewModel.uiState.reapprovalRequired) + assertFalse(viewModel.uiState.validQuote) + } + + private fun finalQuote( + amountIn: BigDecimal = BigDecimal.ONE, + amountOut: BigDecimal = BigDecimal.ONE, + amountInMax: BigDecimal? = null, + cautions: List = emptyList(), + ): ISwapFinalQuote = SwapFinalQuoteEvm( + tokenIn = token, + tokenOut = token, + amountIn = amountIn, + amountOut = amountOut, + amountOutMin = amountOut, + sendTransactionData = SendTransactionData.Unsupported, + priceImpact = null, + fields = emptyList(), + amountInMax = amountInMax, + cautions = cautions, + ) + + private interface ExactOutProvider : IMultiSwapProvider, IExactOutSwapProvider + @Test fun onTransactionCompleted_btcResultOnChainProvider_savesUidAndCanonicalHashSeparately() = runTest(dispatcher) { val provider = mockk(relaxed = true).stubFetchFinalQuote(testTransaction) diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapPayCoreNavigationTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapPayCoreNavigationTest.kt new file mode 100644 index 00000000000..6f7394a0739 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapPayCoreNavigationTest.kt @@ -0,0 +1,109 @@ +package cash.p.terminal.modules.multiswap + +import cash.p.terminal.modules.multiswap.providers.IMultiSwapProvider +import cash.p.terminal.modules.paycore.PayCoreAssets +import cash.p.terminal.modules.paycore.PayCoreQuote +import cash.p.terminal.modules.paycore.PayCoreTicker +import cash.p.terminal.wallet.Token +import cash.p.terminal.wallet.entities.Coin +import cash.p.terminal.wallet.entities.TokenType +import io.horizontalsystems.core.entities.Blockchain +import io.horizontalsystems.core.entities.BlockchainType +import io.horizontalsystems.core.entities.Currency +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal + +class SwapPayCoreNavigationTest { + + private val rubToken = PayCoreAssets.rubToken + private val usdtToken = Token( + coin = Coin( + uid = "tether", + name = "Tether", + code = "USDT", + marketCapRank = null, + coinGeckoId = null, + image = null, + ), + blockchain = Blockchain(BlockchainType.Ethereum, "Ethereum", null), + type = TokenType.Eip20("0xdac17f958d2ee523a2206206994597c13d831ec7"), + decimals = 6, + ) + private val provider = mockk { + every { id } returns "paycore" + } + + @Test + fun buildPayCorePaymentPage_exactOut_preservesRequestedAmountInParams() { + val targetAmount = BigDecimal("12.5") + val page = requireNotNull( + buildPayCorePaymentPage( + exactOutState(targetAmount), + ), + ) + + val params = page.toPaymentParams("0xReceiveAddress") + + assertEquals(SwapAmountDirection.Out, params.direction) + assertEquals(0, targetAmount.compareTo(requireNotNull(params.requestedAmountOut))) + assertEquals("0xReceiveAddress", params.addressOut) + assertEquals(PayCoreTicker.USDT_ERC20, params.networkType) + } + + private fun exactOutState(targetAmount: BigDecimal): SwapUiState { + val quote = SwapProviderQuote( + provider = provider, + swapQuote = PayCoreQuote( + amountOut = targetAmount, + priceImpact = null, + fields = emptyList(), + tokenIn = rubToken, + tokenOut = usdtToken, + amountIn = BigDecimal("1000"), + serviceFee = BigDecimal.ZERO, + actionRequired = null, + ), + executionMode = SwapExecutionMode.NativeExactOut, + ) + return SwapUiState( + amountIn = quote.amountIn, + displayAmountOut = targetAmount, + tokenIn = rubToken, + tokenOut = usdtToken, + quoting = false, + quotes = listOf(quote), + preferredProvider = provider, + quote = quote, + error = null, + availableBalance = null, + displayBalance = null, + networkFee = null, + networkFeeFiatAmount = null, + feeToken = null, + feeCoinBalance = null, + insufficientFeeBalance = false, + balanceHidden = false, + warningMessage = null, + priceImpact = null, + priceImpactLevel = null, + priceImpactCaution = null, + fiatAmountIn = null, + fiatAmountOut = null, + fiatPriceImpact = null, + currency = Currency("RUB", "₽", 2, 0), + fiatAmountInInputEnabled = false, + fiatAmountOutInputEnabled = false, + fiatPriceImpactLevel = null, + timeout = false, + multiSwapRoute = null, + direction = SwapAmountDirection.Out, + requestedAmountOut = targetAmount, + amountInMax = null, + amountOutAccuracy = SwapAmountAccuracy.Exact, + quoteCautions = emptyList(), + ) + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapQuoteServiceTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapQuoteServiceTest.kt index 50274d0a290..85a9fbc355e 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapQuoteServiceTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapQuoteServiceTest.kt @@ -11,6 +11,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,8 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After @@ -33,44 +36,7 @@ import org.junit.Test import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) -class SwapQuoteServiceTest { - - private val mainDispatcher = UnconfinedTestDispatcher() - - private val tokenIn = mockk() - private val tokenOut = mockk() - - private fun mockProvider( - providerId: String, - quoteAmountOut: BigDecimal = BigDecimal.ONE, - supports: Boolean = true, - ): IMultiSwapProvider { - val expectedTokenIn = tokenIn - val expectedTokenOut = tokenOut - - return mockk(relaxed = true) { - every { id } returns providerId - coEvery { supports(expectedTokenIn, expectedTokenOut) } returns supports - coEvery { fetchQuote(expectedTokenIn, expectedTokenOut, BigDecimal.ONE, any()) } returns mockk(relaxed = true) { - every { amountOut } returns quoteAmountOut - every { tokenIn } returns expectedTokenIn - every { tokenOut } returns expectedTokenOut - every { amountIn } returns BigDecimal.ONE - } - } - } - - @Before - fun setUp() { - // Set Main dispatcher to absorb any leaked exceptions from other test classes - Dispatchers.setMain(mainDispatcher) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - unmockkAll() - } +class SwapQuoteServiceTest : SwapQuoteServiceTestFixture() { @Test fun setTokens_allProvidersSlow_noSupportedSwapProviderError() = runTest { @@ -85,7 +51,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(slowProvider), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -141,7 +107,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(failingProvider), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -158,7 +124,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(unsupported), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -208,7 +174,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(lowerQuoteProvider, higherQuoteProvider), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -228,7 +194,7 @@ class SwapQuoteServiceTest { ) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -262,7 +228,7 @@ class SwapQuoteServiceTest { ) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() assertEquals("higher", service.stateFlow.value.quote?.provider?.id) @@ -288,7 +254,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(provider), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -311,7 +277,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(succeeding, failing), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -335,7 +301,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(networkFailing, amountFailing), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -361,7 +327,7 @@ class SwapQuoteServiceTest { val service = createService(listOf(amountOutOfRange, depositTooSmall), testScheduler) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() val state = service.stateFlow.value @@ -384,7 +350,7 @@ class SwapQuoteServiceTest { ) service.setTokenIn(tokenIn) service.setTokenOut(tokenOut) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) advanceUntilIdle() disabledIdsFlow.value = setOf("higher") @@ -413,30 +379,368 @@ class SwapQuoteServiceTest { // Both tokens chosen but no amount yet -> not quoting. assertFalse(service.stateFlow.value.quoting) - service.setAmount(BigDecimal.ONE) + service.setAmountIn(BigDecimal.ONE) // Changing the amount flips quoting=true immediately, before the debounced fetch // runs, so the swap button shows the spinner and cannot act on a stale quote. assertTrue(service.stateFlow.value.quoting) } - private fun createService( + @Test + fun switchPairs_exactIn_movesQuotedOutputToInput() = runTest { + val provider = mockProvider("provider", quoteAmountOut = BigDecimal("5")) + val service = createService(listOf(provider), testScheduler) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountIn(BigDecimal.ONE) + advanceUntilIdle() + + service.switchPairs() + + val state = service.stateFlow.value + assertEquals(tokenOut, state.tokenIn) + assertEquals(tokenIn, state.tokenOut) + assertEquals(BigDecimal("5"), state.amountIn) + assertEquals(SwapAmountDirection.In, state.direction) + assertNull(state.requestedAmountOut) + assertNull(state.amountInMax) + } + + @Test + fun switchPairs_multiSwap_movesFinalLegOutputToInput() = runTest { + val provider = mockProvider("provider") + val leg1 = providerQuote(provider, BigDecimal.ONE, BigDecimal("2")) + val leg2 = providerQuote(provider, BigDecimal("2"), BigDecimal("9")) + val route = MultiSwapRoute( + intermediateCoin = mockk(), + leg1Quotes = listOf(leg1), + leg2Quotes = listOf(leg2), + commissionReserve = BigDecimal.ZERO, + selectedLeg1Quote = leg1, + selectedLeg2Quote = leg2, + ) + val fetch = mockk { + coEvery { this@mockk(any(), any(), any(), any(), any(), any(), any()) } returns emptyList() + } + val resolver = mockk { + coEvery { findRoute(any(), any(), any(), any(), any()) } returns route + } + val service = createService( + providers = listOf(provider), + scheduler = testScheduler, + fetchSwapQuotesUseCase = fetch, + routeResolver = resolver, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountIn(BigDecimal.ONE) + advanceUntilIdle() + + service.switchPairs() + + assertEquals(BigDecimal("9"), service.stateFlow.value.amountIn) + } + + @Test + fun switchPairs_exactOut_movesRequestedOutputToInputAndClearsExactOutState() = runTest { + val provider = mockProvider("provider") + val exactOutQuote = providerQuote( + provider, + amountIn = BigDecimal("3"), + amountOut = BigDecimal("7"), + amountInMax = BigDecimal("4"), + ) + val fetch = mockk { + coEvery { this@mockk(any(), any(), any(), any(), any(), any(), any()) } returns + listOf(exactOutQuote) + } + val service = createService( + providers = listOf(provider), + scheduler = testScheduler, + fetchSwapQuotesUseCase = fetch, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal("7")) + advanceUntilIdle() + + assertEquals(BigDecimal("7"), service.stateFlow.value.requestedAmountOut) + assertEquals(BigDecimal("4"), service.stateFlow.value.amountInMax) + + service.switchPairs() + + val state = service.stateFlow.value + assertEquals(BigDecimal("7"), state.amountIn) + assertEquals(SwapAmountDirection.In, state.direction) + assertNull(state.requestedAmountOut) + assertNull(state.amountInMax) + } + + @Test + fun setAmountOut_disabledProvider_isNotRequested() = runTest { + val enabled = mockProvider("enabled") + val disabled = mockProvider("disabled") + val fetch = mockk(relaxed = true) + val service = createService( + providers = listOf(enabled, disabled), + scheduler = testScheduler, + disabledIds = setOf("disabled"), + fetchSwapQuotesUseCase = fetch, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal.ONE) + advanceUntilIdle() + + coVerify(exactly = 1) { + fetch( + match { it == listOf(enabled) }, + tokenIn, + tokenOut, + BigDecimal.ONE, + SwapAmountDirection.Out, + any(), + any(), + ) + } + } + + @Test + fun disabledIdsChange_exactOutProviderReEnabled_requotesOnceAndKeepsSelection() = runTest { + val selected = mockProvider("selected") + val reEnabled = mockProvider("re-enabled") + val selectedQuote = providerQuote(selected, BigDecimal("2"), BigDecimal.ONE) + val reEnabledQuote = providerQuote(reEnabled, BigDecimal("3"), BigDecimal.ONE) + val disabledIdsFlow = MutableStateFlow(setOf("re-enabled")) + val fetch = mockk { + coEvery { + this@mockk(any(), any(), any(), any(), any(), any(), any()) + } coAnswers { + firstArg>().mapNotNull { provider -> + when (provider.id) { + "selected" -> selectedQuote + "re-enabled" -> reEnabledQuote + else -> null + } + } + } + } + val service = createService( + providers = listOf(selected, reEnabled), + scheduler = testScheduler, + disabledIdsFlow = disabledIdsFlow, + fetchSwapQuotesUseCase = fetch, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal.ONE) + advanceUntilIdle() + + disabledIdsFlow.value = emptySet() + advanceUntilIdle() + + coVerify(exactly = 2) { + fetch(any(), any(), any(), any(), any(), any(), any()) + } + assertEquals("selected", service.stateFlow.value.quote?.provider?.id) + } + + @Test + fun disabledIdsChange_exactOutProviderDisabled_doesNotRequote() = runTest { + val provider = mockProvider("selected") + val quote = providerQuote(provider, BigDecimal("2"), BigDecimal.ONE) + val disabledIdsFlow = MutableStateFlow(emptySet()) + val fetch = mockk { + coEvery { + this@mockk(any(), any(), any(), any(), any(), any(), any()) + } returns listOf(quote) + } + val service = createService( + providers = listOf(provider), + scheduler = testScheduler, + disabledIdsFlow = disabledIdsFlow, + fetchSwapQuotesUseCase = fetch, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal.ONE) + advanceUntilIdle() + + disabledIdsFlow.value = setOf(provider.id) + advanceUntilIdle() + + coVerify(exactly = 1) { + fetch(any(), any(), any(), any(), any(), any(), any()) + } + assertNull(service.stateFlow.value.quote) + assertTrue(service.stateFlow.value.error is NoExactOutSwapProvider) + } + + @Test + fun disabledIdsChange_duringExactOutQuotation_keepsLoadingUntilSuccess() = runTest { + val selected = mockProvider("selected") + val other = mockProvider("other") + val selectedQuote = providerQuote(selected, BigDecimal("2"), BigDecimal.ONE) + val otherQuote = providerQuote(other, BigDecimal("3"), BigDecimal("2")) + val disabledIdsFlow = MutableStateFlow(emptySet()) + val fetchStarted = CompletableDeferred() + val fetchResult = CompletableDeferred>() + var fetchCount = 0 + val fetch = mockk { + coEvery { + this@mockk(any(), any(), any(), any(), any(), any(), any()) + } coAnswers { + fetchCount++ + if (fetchCount == 1) { + listOf(selectedQuote) + } else { + fetchStarted.complete(Unit) + fetchResult.await() + } + } + } + val service = createService( + providers = listOf(selected, other), + scheduler = testScheduler, + disabledIdsFlow = disabledIdsFlow, + fetchSwapQuotesUseCase = fetch, + ) + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal.ONE) + advanceUntilIdle() + + service.setAmountOut(BigDecimal("2")) + advanceTimeBy(601) + runCurrent() + assertTrue(fetchStarted.isCompleted) + + disabledIdsFlow.value = setOf(selected.id) + runCurrent() + + assertTrue(service.stateFlow.value.quoting) + assertNull(service.stateFlow.value.quote) + assertNull(service.stateFlow.value.error) + + fetchResult.complete(listOf(otherQuote)) + advanceUntilIdle() + + assertFalse(service.stateFlow.value.quoting) + assertEquals(other.id, service.stateFlow.value.quote?.provider?.id) + assertNull(service.stateFlow.value.error) + } + + @Test + fun disabledIdsChange_exactOutProviderReEnabledDuringDebounce_quotesOnce() = runTest { + val provider = mockProvider("re-enabled") + val quote = providerQuote(provider, BigDecimal("2"), BigDecimal.ONE) + val disabledIdsFlow = MutableStateFlow(setOf(provider.id)) + val fetch = mockk { + coEvery { + this@mockk(any(), any(), any(), any(), any(), any(), any()) + } returns listOf(quote) + } + val service = createService( + providers = listOf(provider), + scheduler = testScheduler, + disabledIdsFlow = disabledIdsFlow, + fetchSwapQuotesUseCase = fetch, + ) + advanceUntilIdle() + service.setTokenIn(tokenIn) + service.setTokenOut(tokenOut) + service.setAmountOut(BigDecimal.ONE) + + disabledIdsFlow.value = emptySet() + advanceUntilIdle() + + coVerify(exactly = 1) { + fetch(any(), any(), any(), any(), any(), any(), any()) + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class SwapQuoteServiceTestFixture { + + private val mainDispatcher = UnconfinedTestDispatcher() + + protected val tokenIn = mockk() + protected val tokenOut = mockk() + + protected fun mockProvider( + providerId: String, + quoteAmountOut: BigDecimal = BigDecimal.ONE, + supports: Boolean = true, + ): IMultiSwapProvider { + val expectedTokenIn = tokenIn + val expectedTokenOut = tokenOut + + return mockk(relaxed = true) { + every { id } returns providerId + coEvery { supports(expectedTokenIn, expectedTokenOut) } returns supports + coEvery { + fetchQuote(expectedTokenIn, expectedTokenOut, BigDecimal.ONE, any()) + } returns mockk(relaxed = true) { + every { amountOut } returns quoteAmountOut + every { tokenIn } returns expectedTokenIn + every { tokenOut } returns expectedTokenOut + every { amountIn } returns BigDecimal.ONE + } + } + } + + @Before + fun setUp() { + // Set Main dispatcher to absorb any leaked exceptions from other test classes + Dispatchers.setMain(mainDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + protected fun providerQuote( + provider: IMultiSwapProvider, + amountIn: BigDecimal, + amountOut: BigDecimal, + amountInMax: BigDecimal? = null, + ): SwapProviderQuote = SwapProviderQuote( + provider = provider, + swapQuote = mockk(relaxed = true) { + every { this@mockk.tokenIn } returns this@SwapQuoteServiceTestFixture.tokenIn + every { this@mockk.tokenOut } returns this@SwapQuoteServiceTestFixture.tokenOut + every { this@mockk.amountIn } returns amountIn + every { this@mockk.amountOut } returns amountOut + every { this@mockk.amountInMax } returns amountInMax + }, + ) + + protected fun createService( providers: List, scheduler: TestCoroutineScheduler, disabledIds: Set = emptySet(), + fetchSwapQuotesUseCase: FetchSwapQuotesUseCase? = null, + routeResolver: MultiSwapRouteResolver? = null, ): SwapQuoteService = createService( providers = providers, scheduler = scheduler, disabledIdsFlow = MutableStateFlow(disabledIds), + fetchSwapQuotesUseCase = fetchSwapQuotesUseCase, + routeResolver = routeResolver, ) - private fun createService( + protected fun createService( providers: List, scheduler: TestCoroutineScheduler, disabledIdsFlow: MutableStateFlow>, + fetchSwapQuotesUseCase: FetchSwapQuotesUseCase? = null, + routeResolver: MultiSwapRouteResolver? = null, ): SwapQuoteService { val dispatcher = StandardTestDispatcher(scheduler) - val routeResolver = mockk(relaxed = true) { + val resolvedRouteResolver = routeResolver ?: mockk(relaxed = true) { coEvery { findRoute(any(), any(), any(), any(), any()) } returns null } val repository = mockk(relaxed = true) { @@ -453,8 +757,8 @@ class SwapQuoteServiceTest { } } return SwapQuoteService( - routeResolver, - FetchSwapQuotesUseCase(), + resolvedRouteResolver, + fetchSwapQuotesUseCase ?: FetchSwapQuotesUseCase(mockk(relaxed = true)), repository, registry, TestDispatcherProvider(dispatcher, CoroutineScope(dispatcher)), diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt index 72e0d10fb8e..86c87f61b37 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt @@ -69,6 +69,7 @@ class SwapSelectProviderViewModelTest { quote(providerId = "null", amount = "100", eta = null), quote(providerId = "fast", amount = "100", eta = 120L), ), + SwapAmountDirection.In, assetFiatRateService ) @@ -85,6 +86,7 @@ class SwapSelectProviderViewModelTest { quote(providerId = "fastSameAmount", amount = "100", eta = 120L), quote(providerId = "bestAmount", amount = "200", eta = null), ), + SwapAmountDirection.In, assetFiatRateService ) @@ -103,6 +105,7 @@ class SwapSelectProviderViewModelTest { quote(providerId = "null", amount = "100", eta = null), quote(providerId = "fast", amount = "100", eta = 120L), ), + SwapAmountDirection.In, assetFiatRateService ) viewModel.setSortType(ProviderSortType.BestTime) @@ -114,17 +117,41 @@ class SwapSelectProviderViewModelTest { assertEquals(listOf("fast", "slow", "null"), viewModel.providerIds()) } + @Test + fun sorted_exactOut_ordersByAmountInAscendingAndUsesNegativeDiffForWorseQuote() { + val viewModel = SwapSelectProviderViewModel( + listOf( + quote(providerId = "expensive", amount = "100", amountIn = "12", eta = 100L), + quote(providerId = "best", amount = "100", amountIn = "10", eta = 200L), + ), + SwapAmountDirection.Out, + assetFiatRateService, + ) + + assertEquals(listOf("best", "expensive"), viewModel.providerIds()) + assertEquals(null, viewModel.uiState.quoteViewItems[0].diffWithFirst) + assertEquals( + 0, + viewModel.uiState.quoteViewItems[1].diffWithFirst?.compareTo(BigDecimal("-20")), + ) + } + private fun SwapSelectProviderViewModel.providerIds(): List = uiState.quoteViewItems.map { it.quote.provider.id } - private fun quote(providerId: String, amount: String, eta: Long?): SwapProviderQuote { + private fun quote( + providerId: String, + amount: String, + eta: Long?, + amountIn: String = "1", + ): SwapProviderQuote { val provider = mockk { every { id } returns providerId } val swapQuote = mockk { every { tokenIn } returns testToken every { tokenOut } returns testToken - every { amountIn } returns BigDecimal.ONE + every { this@mockk.amountIn } returns BigDecimal(amountIn) every { amountOut } returns BigDecimal(amount) every { estimationTime } returns eta } diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapViewModelFiatInputTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapViewModelFiatInputTest.kt new file mode 100644 index 00000000000..f3f60c96b07 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapViewModelFiatInputTest.kt @@ -0,0 +1,222 @@ +package cash.p.terminal.modules.multiswap + +import androidx.lifecycle.ViewModelStore +import cash.p.terminal.core.ServiceStateFlow +import cash.p.terminal.modules.paycore.PayCoreAssets +import cash.p.terminal.wallet.MarketKitWrapper +import cash.p.terminal.wallet.managers.IBalanceHiddenManager +import cash.p.terminal.wallet.useCases.WalletUseCase +import io.horizontalsystems.core.CurrencyManager +import io.horizontalsystems.core.entities.Currency +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import java.math.BigDecimal +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +@OptIn(ExperimentalCoroutinesApi::class) +class SwapViewModelFiatInputTest { + + private lateinit var dispatcher: TestDispatcher + private val currency = Currency("USD", "$", 2, 0) + private val tokenOut = PayCoreAssets.rubToken + private val rateFlow = MutableStateFlow(BigDecimal("0.25")) + private val quoteStateFlow = MutableStateFlow( + SwapQuoteService.State( + amountIn = null, + tokenIn = tokenOut, + tokenOut = tokenOut, + quoting = false, + quotes = emptyList(), + preferredProvider = null, + quote = null, + error = null, + multiSwapRoute = null, + direction = SwapAmountDirection.In, + requestedAmountOut = null, + amountInMax = null, + ) + ) + private val quoteService = mockk(relaxed = true) { + every { stateFlow } returns quoteStateFlow + every { swapSettings } returns emptyMap() + } + private val balanceService = mockk(relaxed = true) { + every { stateFlow } returns serviceStateFlow( + TokenBalanceService.State( + balance = null, + displayBalance = null, + error = null, + fee = null, + feeToken = null, + feeCoinBalance = null, + insufficientFeeBalance = false, + ) + ) + } + private val timerService = mockk(relaxed = true) { + every { stateFlow } returns serviceStateFlow(TimerService.State(null, false)) + } + private val networkAvailabilityService = mockk(relaxed = true) { + every { stateFlow } returns serviceStateFlow( + NetworkAvailabilityService.State(networkAvailable = true, error = null) + ) + } + private val marketKit = mockk(relaxed = true) + private val assetFiatRateService = mockk { + every { rateFlow("swap", tokenOut, currency) } returns rateFlow + } + private val currencyManager = mockk { + every { baseCurrency } returns currency + } + private val balanceHiddenManager = mockk(relaxed = true) { + every { anyWalletVisibilityChangedFlow } returns MutableSharedFlow() + } + private val viewModelStore = ViewModelStore() + + @Before + fun setUp() { + dispatcher = StandardTestDispatcher() + Dispatchers.setMain(dispatcher) + startKoin { + modules( + module { + single { balanceHiddenManager } + single { mockk(relaxed = true) } + } + ) + } + } + + @After + fun tearDown() { + viewModelStore.clear() + dispatcher.scheduler.advanceUntilIdle() + Dispatchers.resetMain() + stopKoin() + unmockkAll() + } + + @Test + fun onEnterFiatAmountOut_rateChanges_updatesExactOutAmount() = runTest(dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + clearMocks(quoteService, answers = false, recordedCalls = true) + + viewModel.onEnterFiatAmountOut(BigDecimal.ONE) + advanceUntilIdle() + rateFlow.value = BigDecimal("0.20") + advanceUntilIdle() + + verify(exactly = 1) { quoteService.setAmountOut(BigDecimal("4")) } + verify(exactly = 1) { quoteService.setAmountOut(BigDecimal("5")) } + } + + @Test + fun onEnterFiatAmount_rateChanges_updatesExactInAmount() = runTest(dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + clearMocks(quoteService, answers = false, recordedCalls = true) + + viewModel.onEnterFiatAmount(BigDecimal.ONE) + advanceUntilIdle() + rateFlow.value = BigDecimal("0.20") + advanceUntilIdle() + + verify(exactly = 1) { quoteService.setAmountIn(BigDecimal("4")) } + verify(exactly = 1) { quoteService.setAmountIn(BigDecimal("5")) } + } + + @Test + fun onEnterFiatAmountOut_afterFiatAmountIn_rateChanges_updatesOnlyExactOutAmount() = + runTest(dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEnterFiatAmount(BigDecimal.ONE) + advanceUntilIdle() + viewModel.onEnterFiatAmountOut(BigDecimal("2")) + advanceUntilIdle() + clearMocks(quoteService, answers = false, recordedCalls = true) + + rateFlow.value = BigDecimal("0.50") + advanceUntilIdle() + + verify(exactly = 0) { quoteService.setAmountIn(any()) } + verify(exactly = 1) { quoteService.setAmountOut(BigDecimal("4")) } + } + + @Test + fun onEnterAmountOut_afterFiatInput_rateChanges_doesNotUpdateExactOutAmount() = + runTest(dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEnterFiatAmountOut(BigDecimal.ONE) + advanceUntilIdle() + viewModel.onEnterAmountOut(BigDecimal("7")) + advanceUntilIdle() + clearMocks(quoteService, answers = false, recordedCalls = true) + + rateFlow.value = BigDecimal("0.50") + advanceUntilIdle() + + verify(exactly = 0) { quoteService.setAmountOut(any()) } + } + + @Test + fun onEnterAmount_afterFiatInput_rateChanges_doesNotUpdateExactInAmount() = + runTest(dispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onEnterFiatAmount(BigDecimal.ONE) + advanceUntilIdle() + viewModel.onEnterAmount(BigDecimal("7")) + advanceUntilIdle() + clearMocks(quoteService, answers = false, recordedCalls = true) + + rateFlow.value = BigDecimal("0.50") + advanceUntilIdle() + + verify(exactly = 0) { quoteService.setAmountIn(any()) } + } + + private fun createViewModel(): SwapViewModel { + return SwapViewModel( + quoteService = quoteService, + balanceService = balanceService, + priceImpactService = PriceImpactService(), + currencyManager = currencyManager, + fiatServiceIn = FiatService(assetFiatRateService, dispatcher), + fiatServiceOut = FiatService(assetFiatRateService, dispatcher), + timerService = timerService, + networkAvailabilityService = networkAvailabilityService, + marketKit = marketKit, + tokenIn = null, + tokenOut = null, + ).also { viewModelStore.put("swap", it) } + } + + private companion object { + fun serviceStateFlow(value: T): ServiceStateFlow { + val flow = MutableSharedFlow(replay = 1).also { it.tryEmit(value) } + return ServiceStateFlow(flow.asSharedFlow()) + } + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProviderTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProviderTest.kt new file mode 100644 index 00000000000..9c49bd19251 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/AllBridgeProviderTest.kt @@ -0,0 +1,24 @@ +package cash.p.terminal.modules.multiswap.providers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigDecimal + +class AllBridgeProviderTest { + + @Test + fun subtractFee_nonZeroFee_reducesAmount() { + assertEquals( + BigDecimal("8.5"), + AllBridgeProvider.subtractFee(BigDecimal.TEN, BigDecimal("1.5")), + ) + } + + @Test + fun subtractFee_feeExceedsAmount_throws() { + assertThrows(IllegalArgumentException::class.java) { + AllBridgeProvider.subtractFee(BigDecimal.ONE, BigDecimal.TEN) + } + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/providers/StonFiProviderTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/StonFiProviderTest.kt new file mode 100644 index 00000000000..f9b08ea23a4 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/StonFiProviderTest.kt @@ -0,0 +1,34 @@ +package cash.p.terminal.modules.multiswap.providers + +import cash.p.terminal.modules.multiswap.SwapAmountDirection +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +class StonFiProviderTest { + + @Test + fun minimumAskUnits_exactOut_usesRequestedTargetInsteadOfSlippageMinimum() { + val minimum = stonFiMinimumAskUnits( + direction = SwapAmountDirection.Out, + amount = BigDecimal("1"), + tokenOutDecimals = 9, + simulatedMinimum = "995000006", + ) + + assertEquals(BigInteger("1000000000"), minimum) + } + + @Test + fun minimumAskUnits_exactIn_usesSimulationSlippageMinimum() { + val minimum = stonFiMinimumAskUnits( + direction = SwapAmountDirection.In, + amount = BigDecimal("1"), + tokenOutDecimals = 9, + simulatedMinimum = "995000006", + ) + + assertEquals(BigInteger("995000006"), minimum) + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/providers/SwapHelperTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/SwapHelperTest.kt new file mode 100644 index 00000000000..0d6f9260ff1 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/providers/SwapHelperTest.kt @@ -0,0 +1,38 @@ +package cash.p.terminal.modules.multiswap.providers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class SwapHelperTest { + + @Test + fun requiredInput_amountInMaxPresent_returnsMaximum() { + assertEquals( + BigDecimal("1.2"), + requiredInput(BigDecimal.ONE, BigDecimal("1.2")), + ) + } + + @Test + fun insufficientAllowanceCaution_allowanceBelowRequiredInput_returnsTypedCaution() { + assertTrue( + insufficientAllowanceCaution( + allowance = BigDecimal.ONE, + requiredInput = BigDecimal("1.2"), + ) is InsufficientAllowanceCaution + ) + } + + @Test + fun insufficientAllowanceCaution_allowanceCoversRequiredInput_returnsNull() { + assertNull( + insufficientAllowanceCaution( + allowance = BigDecimal("1.2"), + requiredInput = BigDecimal("1.2"), + ) + ) + } +} diff --git a/app/src/test/java/cash/p/terminal/modules/paycore/PayCoreProviderTest.kt b/app/src/test/java/cash/p/terminal/modules/paycore/PayCoreProviderTest.kt index b986a5182c2..a12740d9410 100644 --- a/app/src/test/java/cash/p/terminal/modules/paycore/PayCoreProviderTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/paycore/PayCoreProviderTest.kt @@ -1,5 +1,6 @@ package cash.p.terminal.modules.paycore +import cash.p.terminal.core.ISendEthereumAdapter import cash.p.terminal.core.TestDispatcherProvider import cash.p.terminal.core.storage.SwapProviderTransactionsStorage import cash.p.terminal.modules.multiswap.ISwapFinalQuote @@ -41,6 +42,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.math.BigDecimal +import kotlin.test.assertFailsWith @OptIn(ExperimentalCoroutinesApi::class) class PayCoreProviderTest { @@ -112,6 +114,29 @@ class PayCoreProviderTest { assertTrue(provider.supports(usdtToken, rubToken)) } + @Test + fun supportsExactOut_supportedPairs_returnsTrue() = runTest { + val provider = createProvider() + + assertTrue(provider.supportsExactOut(rubToken, usdtToken)) + assertTrue(provider.supportsExactOut(usdtToken, rubToken)) + } + + @Test + fun supportsExactOut_unsupportedPair_returnsFalse() = runTest { + val provider = createProvider() + + assertFalse(provider.supportsExactOut(usdtToken, usdtToken)) + } + + @Test + fun supportsExactOut_featureDisabled_returnsFalse() = runTest { + every { featureToggle.isEnabled() } returns false + + val provider = createProvider() + assertFalse(provider.supportsExactOut(usdtToken, rubToken)) + } + @Test fun supports_bscUsdToRub_returnsFalse() = runTest { val bscUsdToken = Token( @@ -217,6 +242,46 @@ class PayCoreProviderTest { assertEquals(0, quote.amountOut.compareTo(BigDecimal("140"))) } + @Test + fun fetchQuoteExactOut_rubToUsdt_calculatesRequiredRub() = runTest { + every { walletUseCase.getWallet(any()) } returns mockk() + every { secureStorage.getVerificationStatus() } returns VerificationStatus.VERIFIED + coEvery { apiService.getRate(any(), any()) } returns rateResponse( + buy = BigDecimal("80"), + sell = BigDecimal("70"), + ) + + val quote = createProvider().fetchQuoteExactOut( + rubToken, + usdtToken, + BigDecimal("2"), + emptyMap(), + ) + + assertEquals(0, quote.amountIn.compareTo(BigDecimal("160"))) + assertEquals(0, quote.amountOut.compareTo(BigDecimal("2"))) + } + + @Test + fun fetchQuoteExactOut_usdtToRub_calculatesRequiredUsdt() = runTest { + every { walletUseCase.getWallet(any()) } returns mockk() + every { secureStorage.getVerificationStatus() } returns VerificationStatus.VERIFIED + coEvery { apiService.getRate(any(), any()) } returns rateResponse( + buy = BigDecimal("80"), + sell = BigDecimal("70"), + ) + + val quote = createProvider().fetchQuoteExactOut( + usdtToken, + rubToken, + BigDecimal("140"), + selectedBankSettings, + ) + + assertEquals(0, quote.amountIn.compareTo(BigDecimal("2"))) + assertEquals(0, quote.amountOut.compareTo(BigDecimal("140"))) + } + @Test fun fetchQuote_rateHasWithdrawFee_usesWithdrawFeeAsServiceFee() = runTest { every { walletUseCase.getWallet(any()) } returns mockk() @@ -255,6 +320,58 @@ class PayCoreProviderTest { assertEquals(BigDecimal("100"), finalQuote.amountOut) } + @Test + fun fetchFinalQuoteExactOut_payoutRequestsTargetRubAmount() = runTest { + coEvery { walletUseCase.getReceiveAddress(any()) } returns "0xMyAddr" + val adapter = mockk { + every { getTransactionData(any(), any()) } returns mockk() + } + every { adapterManager.getAdapterForToken(any()) } returns adapter + + createProvider().fetchFinalQuoteExactOut( + tokenIn = usdtToken, + tokenOut = rubToken, + amountOut = BigDecimal("100"), + swapSettings = selectedBankSettings, + sendTransactionSettings = null, + swapQuote = payCoreQuote(serviceFee = BigDecimal("2")), + ) + + coVerify(exactly = 1) { + apiService.calculatePayout( + match { + it.amount == BigDecimal("100") && + it.amountType == PayCoreAmountType.RUB + }, + PayCoreTicker.USDT_ERC20, + ) + } + } + + @Test + fun fetchFinalQuoteExactOut_targetMismatch_doesNotCreatePayout() = runTest { + coEvery { apiService.calculatePayout(any(), any()) } returns PayCorePayoutCalculationResponse( + amountCrypto = BigDecimal.ONE, + fullAmountRub = BigDecimal("99"), + ticker = "USDT_ERC20", + uuid = "calculation-uuid", + expiresAt = "2026-06-05T00:00:00Z", + ) + + assertFailsWith { + createProvider().fetchFinalQuoteExactOut( + tokenIn = usdtToken, + tokenOut = rubToken, + amountOut = BigDecimal("100"), + swapSettings = selectedBankSettings, + sendTransactionSettings = null, + swapQuote = payCoreQuote(serviceFee = BigDecimal("2")), + ) + } + + coVerify(exactly = 0) { apiService.createPayout(any(), any()) } + } + @Test fun onTransactionCompleted_payoutCreated_savesOrderWithOutgoingTxHash() = runTest { val txHash = "0xdeadbeef" @@ -364,10 +481,10 @@ class PayCoreProviderTest { private suspend fun prepareFinalQuote(provider: PayCoreProvider): ISwapFinalQuote { coEvery { walletUseCase.getReceiveAddress(any()) } returns "0xMyAddr" - val adapter = mockk { + val adapter = mockk { every { getTransactionData(any(), any()) } returns mockk() } - every { adapterManager.getAdapterForToken(any()) } returns adapter + every { adapterManager.getAdapterForToken(any()) } returns adapter return provider.fetchFinalQuote( usdtToken, diff --git a/app/src/test/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModelTest.kt b/app/src/test/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModelTest.kt index 673bdb70649..a26c956e8b8 100644 --- a/app/src/test/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModelTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/paycore/payment/PayCorePaymentViewModelTest.kt @@ -3,6 +3,7 @@ package cash.p.terminal.modules.paycore.payment import cash.p.terminal.R import cash.p.terminal.entities.SwapProviderTransaction import cash.p.terminal.core.storage.SwapProviderTransactionsStorage +import cash.p.terminal.modules.multiswap.SwapAmountDirection import cash.p.terminal.modules.paycore.PayCoreApiService import cash.p.terminal.modules.paycore.PayCoreAmountType import cash.p.terminal.modules.paycore.PayCorePaymentCalculationRequest @@ -414,6 +415,81 @@ class PayCorePaymentViewModelTest { assertEquals(PayCoreTicker.USDT, requestSlot.captured.ticker) } + @Test + fun init_exactOut_requestsTargetCryptoAmount() = runTest(dispatcher) { + val requestSlot = slot() + coEvery { + apiService.calculatePayment(capture(requestSlot), any()) + } returns PayCorePaymentCalculationResponse( + amountCrypto = BigDecimal("12.5"), + fullAmountRub = BigDecimal("1000"), + ticker = "USDT", + uuid = "calculation-uuid", + expiresAt = "2026-06-05T00:00:30Z", + ) + + createViewModel( + direction = SwapAmountDirection.Out, + requestedAmountOut = BigDecimal("12.5"), + ) + advanceUntilIdle() + + assertEquals(BigDecimal("12.5"), requestSlot.captured.amount) + assertEquals(PayCoreAmountType.CRYPTO, requestSlot.captured.amountType) + } + + @Test + fun onConfirm_expiredExactOutPayment_recalculatesTargetCryptoAmount() = runTest(dispatcher) { + givenPaymentCreateResponse( + paymentUrl = "https://pirate.paycore.pw/pay/expired", + uuid = "expired-payment", + expiresAt = pastExpiresAt(), + ) + val targetAmount = BigDecimal("20") + val viewModel = createViewModel( + direction = SwapAmountDirection.Out, + requestedAmountOut = targetAmount, + ) + advanceUntilIdle() + + viewModel.onConfirm() + advanceUntilIdle() + viewModel.onWebViewClosed() + viewModel.onConfirm() + advanceUntilIdle() + + coVerify(exactly = 2) { + apiService.calculatePayment( + match { + it.amount.compareTo(targetAmount) == 0 && + it.amountType == PayCoreAmountType.CRYPTO + }, + PayCoreTicker.USDT, + ) + } + } + + @Test + fun init_exactOutTargetMismatch_doesNotExposeCalculation() = runTest(dispatcher) { + coEvery { apiService.calculatePayment(any(), any()) } returns PayCorePaymentCalculationResponse( + amountCrypto = BigDecimal("12.4"), + fullAmountRub = BigDecimal("1000"), + ticker = "USDT", + uuid = "calculation-uuid", + expiresAt = "2026-06-05T00:00:30Z", + ) + + val viewModel = createViewModel( + direction = SwapAmountDirection.Out, + requestedAmountOut = BigDecimal("12.5"), + ) + advanceUntilIdle() + + assertNull(viewModel.uiState.calculationUuid) + assertEquals(Translator.getString(R.string.paycore_generic_error), viewModel.uiState.error) + coVerify(exactly = 0) { apiService.createPayment(any(), any()) } + } + private fun givenActiveAccount(id: String = "account-id") { val account = mockk() every { account.id } returns id @@ -444,7 +520,10 @@ class PayCorePaymentViewModelTest { return Instant.now().minusSeconds(1).toString() } - private fun createViewModel() = PayCorePaymentViewModel( + private fun createViewModel( + direction: SwapAmountDirection = SwapAmountDirection.In, + requestedAmountOut: BigDecimal? = null, + ) = PayCorePaymentViewModel( apiService = apiService, walletApprovalService = walletApprovalService, storage = storage, @@ -457,7 +536,9 @@ class PayCorePaymentViewModelTest { tokenOutUid = "tether", blockchainTypeIn = "unsupported", blockchainTypeOut = "tron", - addressOut = "TUserUsdtAddress" + addressOut = "TUserUsdtAddress", + direction = direction, + requestedAmountOut = requestedAmountOut, ) ) } diff --git a/app/src/test/java/cash/p/terminal/screenshots/SwapOutputPreviewScreenshotTest.kt b/app/src/test/java/cash/p/terminal/screenshots/SwapOutputPreviewScreenshotTest.kt new file mode 100644 index 00000000000..ae2a7b950a6 --- /dev/null +++ b/app/src/test/java/cash/p/terminal/screenshots/SwapOutputPreviewScreenshotTest.kt @@ -0,0 +1,54 @@ +package cash.p.terminal.screenshots + +import android.app.Application +import cash.p.terminal.modules.multiswap.SwapOutputInputExactInPreview +import cash.p.terminal.modules.multiswap.SwapOutputInputExactOutPreview +import cash.p.terminal.modules.multiswap.SwapOutputInputNoPriceImpactPreview +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Local-only design check. Run with: + * `./gradlew :app:recordRoborazziDebug -Pscreenshots --tests "*SwapOutputPreviewScreenshotTest"`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config( + sdk = [34], + application = Application::class, + qualifiers = RobolectricDeviceQualifiers.Pixel5, +) +class SwapOutputPreviewScreenshotTest { + + @Test + fun snapshot_exactIn_rendersOutputField() { + captureRoboImage( + filePath = "build/outputs/roborazzi/SwapOutputInputExactInPreview.png", + ) { + SwapOutputInputExactInPreview() + } + } + + @Test + fun snapshot_exactOut_rendersOutputField() { + captureRoboImage( + filePath = "build/outputs/roborazzi/SwapOutputInputExactOutPreview.png", + ) { + SwapOutputInputExactOutPreview() + } + } + + @Test + fun snapshot_noPriceImpact_rendersExpandedFiatField() { + captureRoboImage( + filePath = "build/outputs/roborazzi/SwapOutputInputNoPriceImpactPreview.png", + ) { + SwapOutputInputNoPriceImpactPreview() + } + } +} diff --git a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/api/StonFiApi.kt b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/api/StonFiApi.kt index 6cb0f04d204..27b30d9c6c8 100644 --- a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/api/StonFiApi.kt +++ b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/api/StonFiApi.kt @@ -27,13 +27,36 @@ internal class StonFiApi( askAddress: String, units: String, slippageTolerance: String, + direction: SimulationDirection, poolAddress: String? = null, referralAddress: String? = null, referralFeeBps: Int? = null, dexVersion: Int? = null + ): SimulateSwapDto = simulateSwap( + path = direction.path, + offerAddress = offerAddress, + askAddress = askAddress, + units = units, + slippageTolerance = slippageTolerance, + poolAddress = poolAddress, + referralAddress = referralAddress, + referralFeeBps = referralFeeBps, + dexVersion = dexVersion, + ) + + private suspend fun simulateSwap( + path: String, + offerAddress: String, + askAddress: String, + units: String, + slippageTolerance: String, + poolAddress: String?, + referralAddress: String?, + referralFeeBps: Int?, + dexVersion: Int?, ): SimulateSwapDto { return httpClient.post { - url(BASE_URL + "v1/swap/simulate") + url(BASE_URL + path) parameter("offer_address", offerAddress) parameter("ask_address", askAddress) parameter("units", units) @@ -94,3 +117,8 @@ internal class StonFiApi( } } } + +internal enum class SimulationDirection(val path: String) { + Forward("v1/swap/simulate"), + Reverse("v1/reverse_swap/simulate"), +} diff --git a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/data/repository/StonFiRepositoryImpl.kt b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/data/repository/StonFiRepositoryImpl.kt index 450199eb24e..a1da1e6bf73 100644 --- a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/data/repository/StonFiRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/data/repository/StonFiRepositoryImpl.kt @@ -1,6 +1,7 @@ package cash.p.terminal.network.stonfi.data.repository import cash.p.terminal.network.stonfi.api.StonFiApi +import cash.p.terminal.network.stonfi.api.SimulationDirection import cash.p.terminal.network.stonfi.data.mapper.StonFiMapper import cash.p.terminal.network.stonfi.domain.entity.Asset import cash.p.terminal.network.stonfi.domain.entity.RouterInfo @@ -55,12 +56,56 @@ internal class StonFiRepositoryImpl( referralAddress: String?, referralFeeBps: Int?, dexVersion: Int? + ): SimulateSwap = simulateSwap( + offerAddress, + askAddress, + units, + slippageTolerance, + SimulationDirection.Forward, + poolAddress, + referralAddress, + referralFeeBps, + dexVersion, + ) + + override suspend fun reverseSimulateSwap( + offerAddress: String, + askAddress: String, + units: String, + slippageTolerance: BigDecimal, + poolAddress: String?, + referralAddress: String?, + referralFeeBps: Int?, + dexVersion: Int? + ): SimulateSwap = simulateSwap( + offerAddress, + askAddress, + units, + slippageTolerance, + SimulationDirection.Reverse, + poolAddress, + referralAddress, + referralFeeBps, + dexVersion, + ) + + private suspend fun simulateSwap( + offerAddress: String, + askAddress: String, + units: String, + slippageTolerance: BigDecimal, + direction: SimulationDirection, + poolAddress: String?, + referralAddress: String?, + referralFeeBps: Int?, + dexVersion: Int?, ): SimulateSwap = withContext(Dispatchers.IO) { stonFiApi.simulateSwap( offerAddress = offerAddress, askAddress = askAddress, units = units, slippageTolerance = slippageTolerance.divide(BigDecimal(100)).toString(), + direction = direction, poolAddress = poolAddress, referralAddress = referralAddress, referralFeeBps = referralFeeBps, diff --git a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/domain/repository/StonFiRepository.kt b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/domain/repository/StonFiRepository.kt index b69e032dd4e..d6530541f1e 100644 --- a/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/domain/repository/StonFiRepository.kt +++ b/core/network/src/commonMain/kotlin/cash/p/terminal/network/stonfi/domain/repository/StonFiRepository.kt @@ -28,6 +28,17 @@ interface StonFiRepository { dexVersion: Int? = null ): SimulateSwap + suspend fun reverseSimulateSwap( + offerAddress: String, + askAddress: String, + units: String, + slippageTolerance: BigDecimal, + poolAddress: String? = null, + referralAddress: String? = null, + referralFeeBps: Int? = null, + dexVersion: Int? = null + ): SimulateSwap + suspend fun getSwapStatus( routerAddress: String, ownerAddress: String, diff --git a/core/strings/src/main/res/values-ar/strings.xml b/core/strings/src/main/res/values-ar/strings.xml index 41d383a338e..848603f51dc 100644 --- a/core/strings/src/main/res/values-ar/strings.xml +++ b/core/strings/src/main/res/values-ar/strings.xml @@ -646,6 +646,9 @@ موفرو التبادل لا يمكن تعطيل هذا الموفر لا يوجد موفرون مفعَّلون + المبلغ المستلم تقديري وقد يتغير. + لم يعد الحد المعتمد كافيًا. اعتمد المبلغ المحدّث. + اعتماد الحد المحدّث لا يوجد مزودون لهذا المبلغ diff --git a/core/strings/src/main/res/values-de/strings.xml b/core/strings/src/main/res/values-de/strings.xml index c33f7a6b180..ad1c98f76c8 100644 --- a/core/strings/src/main/res/values-de/strings.xml +++ b/core/strings/src/main/res/values-de/strings.xml @@ -641,6 +641,9 @@ Tausch-Anbieter Dieser Anbieter kann nicht deaktiviert werden Keine aktiven Anbieter + Der Empfangsbetrag ist geschätzt und kann sich ändern. + Das genehmigte Limit reicht nicht mehr aus. Genehmige den aktualisierten Betrag. + Aktualisiertes Limit genehmigen Keine Anbieter für diesen Betrag Sie können %s tauschen, oder Sie müssen den neuen Betrag widerrufen und genehmigen diff --git a/core/strings/src/main/res/values-es/strings.xml b/core/strings/src/main/res/values-es/strings.xml index 4b6c5cbc330..bd340b869e9 100644 --- a/core/strings/src/main/res/values-es/strings.xml +++ b/core/strings/src/main/res/values-es/strings.xml @@ -642,6 +642,9 @@ Proveedores de intercambio Este proveedor no puede desactivarse Sin proveedores activos + El importe recibido es estimado y puede cambiar. + El límite aprobado ya no es suficiente. Aprueba el importe actualizado. + Aprobar límite actualizado Sin proveedores para este importe diff --git a/core/strings/src/main/res/values-fa/strings.xml b/core/strings/src/main/res/values-fa/strings.xml index 8ff28d6588b..c73fa98561a 100644 --- a/core/strings/src/main/res/values-fa/strings.xml +++ b/core/strings/src/main/res/values-fa/strings.xml @@ -647,6 +647,9 @@ ارائه‌دهندگان سواپ این ارائه‌دهنده را نمی‌توان غیرفعال کرد هیچ ارائه‌دهنده فعالی وجود ندارد + مبلغ دریافتی تخمینی است و ممکن است تغییر کند. + سقف تأییدشده دیگر کافی نیست. مبلغ به‌روزشده را تأیید کنید. + تأیید سقف به‌روزشده هیچ ارائه‌دهنده‌ای برای این مبلغ وجود ندارد diff --git a/core/strings/src/main/res/values-fr/strings.xml b/core/strings/src/main/res/values-fr/strings.xml index 3a2929c9ac2..cde9cc262c7 100644 --- a/core/strings/src/main/res/values-fr/strings.xml +++ b/core/strings/src/main/res/values-fr/strings.xml @@ -643,6 +643,9 @@ Fournisseurs de swap Ce fournisseur ne peut pas être désactivé Aucun fournisseur actif + Le montant reçu est estimé et peut changer. + La limite approuvée n’est plus suffisante. Approuvez le montant mis à jour. + Approuver la limite mise à jour Aucun fournisseur pour ce montant diff --git a/core/strings/src/main/res/values-ko/strings.xml b/core/strings/src/main/res/values-ko/strings.xml index 699efb83bba..28298e3d179 100644 --- a/core/strings/src/main/res/values-ko/strings.xml +++ b/core/strings/src/main/res/values-ko/strings.xml @@ -647,6 +647,9 @@ 스왑 제공자 이 제공자는 비활성화할 수 없습니다 활성화된 제공자 없음 + 받을 금액은 예상치이며 변경될 수 있습니다. + 승인된 한도가 더 이상 충분하지 않습니다. 변경된 금액을 다시 승인하세요. + 변경된 한도 승인 이 금액에 대한 제공자가 없습니다 diff --git a/core/strings/src/main/res/values-nl/strings.xml b/core/strings/src/main/res/values-nl/strings.xml index 7bf88297cb9..1bbdb55412d 100644 --- a/core/strings/src/main/res/values-nl/strings.xml +++ b/core/strings/src/main/res/values-nl/strings.xml @@ -648,6 +648,9 @@ Swap-aanbieders Deze aanbieder kan niet worden uitgeschakeld Geen actieve aanbieders + Het te ontvangen bedrag is een schatting en kan veranderen. + De goedgekeurde limiet is niet meer voldoende. Keur het bijgewerkte bedrag goed. + Bijgewerkte limiet goedkeuren Geen aanbieders voor dit bedrag diff --git a/core/strings/src/main/res/values-pt-rBR/strings.xml b/core/strings/src/main/res/values-pt-rBR/strings.xml index 576586376c2..a33ef1ccb2c 100644 --- a/core/strings/src/main/res/values-pt-rBR/strings.xml +++ b/core/strings/src/main/res/values-pt-rBR/strings.xml @@ -647,6 +647,9 @@ Provedores de swap Este provedor não pode ser desativado Nenhum provedor ativo + O valor recebido é estimado e pode mudar. + O limite aprovado não é mais suficiente. Aprove o valor atualizado. + Aprovar limite atualizado Sem provedores para este valor Você pode trocar %s, ou você precisa revogar e aprovar o novo valor diff --git a/core/strings/src/main/res/values-pt/strings.xml b/core/strings/src/main/res/values-pt/strings.xml index 9c1d032d9fa..6609c3568c2 100644 --- a/core/strings/src/main/res/values-pt/strings.xml +++ b/core/strings/src/main/res/values-pt/strings.xml @@ -647,6 +647,9 @@ Provedores de swap Este provedor não pode ser desativado Nenhum provedor ativo + O valor recebido é estimado e pode mudar. + O limite aprovado já não é suficiente. Aprove o valor atualizado. + Aprovar limite atualizado Sem fornecedores para este valor diff --git a/core/strings/src/main/res/values-ru/strings.xml b/core/strings/src/main/res/values-ru/strings.xml index 5f99a6b35b8..fe7768b9d94 100644 --- a/core/strings/src/main/res/values-ru/strings.xml +++ b/core/strings/src/main/res/values-ru/strings.xml @@ -655,6 +655,9 @@ Провайдеры обмена Этот провайдер нельзя отключить Нет включённых провайдеров + Сумма получения рассчитана приблизительно и может измениться. + Одобренного лимита больше недостаточно. Одобрите обновлённую сумму. + Одобрить лимит повторно Нет провайдеров для этой суммы diff --git a/core/strings/src/main/res/values-tr/strings.xml b/core/strings/src/main/res/values-tr/strings.xml index 51b340fdbac..a231f1514ee 100644 --- a/core/strings/src/main/res/values-tr/strings.xml +++ b/core/strings/src/main/res/values-tr/strings.xml @@ -647,6 +647,9 @@ Takas sağlayıcıları Bu sağlayıcı devre dışı bırakılamaz Etkin sağlayıcı yok + Alınacak tutar tahminidir ve değişebilir. + Onaylanan limit artık yeterli değil. Güncellenen tutarı onaylayın. + Güncellenen limiti onayla Bu miktar için sağlayıcı yok diff --git a/core/strings/src/main/res/values-uk/strings.xml b/core/strings/src/main/res/values-uk/strings.xml index 86cc82d646d..f4c4b7f97fb 100644 --- a/core/strings/src/main/res/values-uk/strings.xml +++ b/core/strings/src/main/res/values-uk/strings.xml @@ -635,6 +635,9 @@ Провайдери обміну Цього провайдера не можна вимкнути Немає увімкнених провайдерів + Сума отримання є орієнтовною та може змінитися. + Схваленого ліміту вже недостатньо. Схваліть оновлену суму. + Схвалити оновлений ліміт Немає провайдерів для цієї суми У вас недостатньо балансу XLM для активації цього токена. diff --git a/core/strings/src/main/res/values-zh/strings.xml b/core/strings/src/main/res/values-zh/strings.xml index 536568aa391..33912aea47c 100644 --- a/core/strings/src/main/res/values-zh/strings.xml +++ b/core/strings/src/main/res/values-zh/strings.xml @@ -643,6 +643,9 @@ 兑换服务商 此服务商无法禁用 没有已启用的服务商 + 接收金额为估算值,可能会发生变化。 + 已批准的额度不再足够。请批准更新后的金额。 + 批准更新后的额度 没有适用于此金额的服务商 diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml index e43476be8d6..bbdee4fd848 100644 --- a/core/strings/src/main/res/values/strings.xml +++ b/core/strings/src/main/res/values/strings.xml @@ -865,6 +865,9 @@ Proceed only if you\'re sure. Swap Providers This provider cannot be disabled No enabled providers + The received amount is estimated and may change. + The approved limit is no longer sufficient. Approve the updated amount. + Approve updated limit No providers for this amount diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2763ee00116..6a983c41b85 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,7 +68,7 @@ piratecash-ton = "v1.0.0-pcash.6" piratecash-bitcoin = "v0.1.0-pcash.26" stellar-kit = "v1.0.0-pcash.3" trezor-kit = "1.2.0" -piratecash-ethereum = "v0.1.0-pcash.8" +piratecash-ethereum = "v0.1.0-pcash.9" horizontalsystems-blockchain-fee = "393cc14" piratecash-solana = "v1.0.0-pcash.10" monero-kit-android = "v0.18.3.4-pcash.8" From fc44e432f961b1137a9f2b5ee2176c0ffeb130e5 Mon Sep 17 00:00:00 2001 From: Oleg Leonov Date: Sat, 1 Aug 2026 21:28:51 +0300 Subject: [PATCH 2/3] Add auto calculation for fiat/coin in out field --- .../modules/multiswap/SwapFragment.kt | 6 +++++- .../multiswap/SwapSelectProviderViewModel.kt | 21 ++++++++++++++++--- .../modules/multiswap/SwapViewModel.kt | 2 ++ .../SwapSelectProviderViewModelTest.kt | 21 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt index 601867f485d..ec01cf7d81f 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapFragment.kt @@ -250,7 +250,11 @@ fun SwapScreen(navController: NavController, tokenIn: Token?, tokenOut: Token?) } val selectProviderViewModel = viewModel( viewModelStoreOwner = backStackEntry, - factory = SwapSelectProviderViewModel.Factory(quotes, viewModel.uiState.direction) + factory = SwapSelectProviderViewModel.Factory( + quotes = quotes, + direction = viewModel.uiState.direction, + quoteUpdates = viewModel.quotesFlow, + ) ) val swapProvidersRepository = remember { getKoinInstance() } val disabledIds by swapProvidersRepository.disabledIds.collectAsStateWithLifecycle() diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt index d242fa702d5..7ee7fb11d8b 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModel.kt @@ -12,14 +12,17 @@ import cash.p.terminal.entities.CoinValue import cash.p.terminal.wallet.Token import io.horizontalsystems.core.ViewModelUiState import io.horizontalsystems.core.entities.CurrencyValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode class SwapSelectProviderViewModel( - private val quotes: List, + private var quotes: List, private val direction: SwapAmountDirection, - private val assetFiatRateService: AssetFiatRateService = getKoinInstance() + private val assetFiatRateService: AssetFiatRateService = getKoinInstance(), + quoteUpdates: Flow> = emptyFlow(), ) : ViewModelUiState() { private val currencyManager = App.currencyManager @@ -41,6 +44,13 @@ class SwapSelectProviderViewModel( rebuildViewItems() } } + viewModelScope.launch { + quoteUpdates.collect { + if (quotes == it) return@collect + quotes = it + rebuildViewItems() + } + } } private fun rebuildViewItems() { @@ -184,10 +194,15 @@ class SwapSelectProviderViewModel( class Factory( private val quotes: List, private val direction: SwapAmountDirection, + private val quoteUpdates: Flow> = emptyFlow(), ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return SwapSelectProviderViewModel(quotes, direction) as T + return SwapSelectProviderViewModel( + quotes = quotes, + direction = direction, + quoteUpdates = quoteUpdates, + ) as T } } } diff --git a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt index d9216cd1cee..87631be04da 100644 --- a/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt +++ b/app/src/main/java/cash/p/terminal/modules/multiswap/SwapViewModel.kt @@ -19,6 +19,7 @@ import cash.p.terminal.wallet.useCases.WalletUseCase import io.horizontalsystems.core.CurrencyManager import io.horizontalsystems.core.ViewModelUiState import io.horizontalsystems.core.entities.Currency +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import java.math.BigDecimal @@ -39,6 +40,7 @@ class SwapViewModel( ) : ViewModelUiState() { private val quoteLifetime = 20 + internal val quotesFlow = quoteService.stateFlow.map { it.quotes } private var networkState = networkAvailabilityService.stateFlow.value private var quoteState = quoteService.stateFlow.value diff --git a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt index 86c87f61b37..b28b212b832 100644 --- a/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt +++ b/app/src/test/java/cash/p/terminal/modules/multiswap/SwapSelectProviderViewModelTest.kt @@ -13,9 +13,12 @@ import io.mockk.mockkObject import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals @@ -136,6 +139,24 @@ class SwapSelectProviderViewModelTest { ) } + @Test + fun quoteUpdates_newProviderAdded_rebuildsItems() = runTest(dispatcher) { + val initialQuote = quote(providerId = "initial", amount = "100", eta = 100L) + val newQuote = quote(providerId = "new", amount = "110", eta = 200L) + val quoteUpdates = MutableStateFlow(listOf(initialQuote)) + val viewModel = SwapSelectProviderViewModel( + quotes = quoteUpdates.value, + direction = SwapAmountDirection.Out, + assetFiatRateService = assetFiatRateService, + quoteUpdates = quoteUpdates, + ) + + quoteUpdates.value = listOf(initialQuote, newQuote) + advanceUntilIdle() + + assertEquals(listOf("initial", "new"), viewModel.providerIds()) + } + private fun SwapSelectProviderViewModel.providerIds(): List = uiState.quoteViewItems.map { it.quote.provider.id } From 3d0918dfeb5a4312a01021aa66bd9f244b6cb3c9 Mon Sep 17 00:00:00 2001 From: Oleg Leonov Date: Sat, 1 Aug 2026 22:08:31 +0300 Subject: [PATCH 3/3] Add auto calculation for fiat/coin in out field --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a983c41b85..8d534afd5c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,7 +68,7 @@ piratecash-ton = "v1.0.0-pcash.6" piratecash-bitcoin = "v0.1.0-pcash.26" stellar-kit = "v1.0.0-pcash.3" trezor-kit = "1.2.0" -piratecash-ethereum = "v0.1.0-pcash.9" +piratecash-ethereum = "v0.1.0-pcash.10" horizontalsystems-blockchain-fee = "393cc14" piratecash-solana = "v1.0.0-pcash.10" monero-kit-android = "v0.18.3.4-pcash.8"