From 291282e775ecf8213d0199c43306e425c647a10b Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 6 May 2026 16:13:37 +0500 Subject: [PATCH 1/2] Organize feature/swap into coordinators Move swap from cases + repository to the application/swap/coordinators pattern used by the rest of the codebase. Extracts six coordinators covering quote requests, quote-data retrieval, supported chains, ConfirmParams construction, swap-asset search, and swap quote refresh orchestration. Side fixes: - Permit2 expiration now reads from Rust core's SwapConfig (permit2_expiration / permit2_sig_deadline) instead of hardcoded values; previous Android value was 30 hours instead of 30 days. - Restore SyncAssetInfoImplTest compile by using coVerify for the suspend addAssetIds call. Closes #199 --- .../android/ui/navigation/RootRoute.kt | 2 +- .../android/ui/navigation/routes/Swap.kt | 2 +- .../ui/navigation/WalletNavigatorTest.kt | 2 +- .../data/coordinators/di/SwapModule.kt | 89 ++++++++++ .../swap/BuildSwapConfirmParamsImpl.kt | 55 ++++++ .../coordinators/swap/GetSwapQuoteDataImpl.kt | 75 +++++++++ .../coordinators/swap/GetSwapQuotesImpl.kt | 53 ++++++ .../coordinators/swap/GetSwapSupportedImpl.kt | 15 ++ .../swap/RequestSwapQuotesImpl.kt} | 46 +++--- .../coordinators/swap/SearchSwapAssetsImpl.kt | 73 ++++++++ .../asset/SyncAssetInfoImplTest.kt | 5 +- .../swap/RequestSwapQuotesImplTest.kt} | 75 ++++----- .../swap/SearchSwapAssetsImplTest.kt | 78 +++++++++ .../data/repositories/di/SwapModule.kt | 49 ------ .../data/repositories/swap/SwapRepository.kt | 123 -------------- .../android/features/swap/views/SwapScene.kt | 2 +- .../android/features/swap/views/SwapScreen.kt | 2 +- .../features/swap/views/SwapSelectScreen.kt | 2 +- .../swap/viewmodels/SwapSelectViewModel.kt | 93 +++-------- .../features/swap/viewmodels/SwapViewModel.kt | 88 ++++------ .../swap/viewmodels/models/QuoteUiState.kt | 11 +- .../swap/viewmodels/models/SwapUiState.kt | 9 +- .../viewmodels/models/TransferDataUiState.kt | 5 +- .../swap/viewmodels/SwapSelectSearchTest.kt | 95 +++++++---- .../swap/viewmodels/SwapViewModelTest.kt | 156 +++++++++--------- .../coordinators/BuildSwapConfirmParams.kt | 15 ++ .../swap/coordinators/GetSwapQuoteData.kt | 9 + .../swap/coordinators}/GetSwapQuotes.kt | 4 +- .../swap/coordinators}/GetSwapSupported.kt | 4 +- .../swap/coordinators/RequestSwapQuotes.kt | 17 ++ .../swap/coordinators/SearchSwapAssets.kt | 18 ++ .../coordinators/SwapQuoteRequestParams.kt} | 16 +- .../swap/coordinators/SwapQuotesResult.kt} | 10 +- .../android/domains/swap}/SwapItemType.kt | 4 +- 34 files changed, 793 insertions(+), 509 deletions(-) create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/SwapModule.kt create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/BuildSwapConfirmParamsImpl.kt create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuotesImpl.kt create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapSupportedImpl.kt rename android/{features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequester.kt => data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt} (67%) create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt rename android/{features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequesterTest.kt => data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImplTest.kt} (77%) create mode 100644 android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImplTest.kt delete mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/SwapModule.kt delete mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/swap/SwapRepository.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/BuildSwapConfirmParams.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuoteData.kt rename android/gemcore/src/main/kotlin/com/gemwallet/android/{cases/swap => application/swap/coordinators}/GetSwapQuotes.kt (83%) rename android/gemcore/src/main/kotlin/com/gemwallet/android/{cases/swap => application/swap/coordinators}/GetSwapSupported.kt (74%) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SearchSwapAssets.kt rename android/{features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteRequestParams.kt => gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuoteRequestParams.kt} (69%) rename android/{features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuotesState.kt => gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuotesResult.kt} (57%) rename android/{features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models => gemcore/src/main/kotlin/com/gemwallet/android/domains/swap}/SwapItemType.kt (62%) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index b87a488693..b1afe917fa 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -20,7 +20,7 @@ import com.gemwallet.android.features.onboarding.AcceptTermsDestination import com.gemwallet.android.features.onboarding.AcceptTermsRoute import com.gemwallet.android.features.onboarding.OnboardingRoute import com.gemwallet.android.features.setup_wallet.navigation.SetupWalletRoute -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.model.AmountParams import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.ImportType diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Swap.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Swap.kt index 6721b18411..06d4ddd51b 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Swap.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Swap.kt @@ -3,7 +3,7 @@ package com.gemwallet.android.ui.navigation.routes import androidx.compose.runtime.Composable import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.features.swap.views.SwapScreen import com.gemwallet.android.features.swap.views.SwapSelectScreen import com.gemwallet.android.model.ConfirmParams diff --git a/android/app/src/test/kotlin/com/gemwallet/android/ui/navigation/WalletNavigatorTest.kt b/android/app/src/test/kotlin/com/gemwallet/android/ui/navigation/WalletNavigatorTest.kt index 32ff778b5f..a3cfd384ec 100644 --- a/android/app/src/test/kotlin/com/gemwallet/android/ui/navigation/WalletNavigatorTest.kt +++ b/android/app/src/test/kotlin/com/gemwallet/android/ui/navigation/WalletNavigatorTest.kt @@ -13,7 +13,7 @@ import com.gemwallet.android.features.onboarding.AcceptTermsDestination import com.gemwallet.android.features.onboarding.AcceptTermsRoute import com.gemwallet.android.features.onboarding.OnboardingRoute import com.gemwallet.android.features.setup_wallet.navigation.SetupWalletRoute -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.model.ImportType import com.gemwallet.android.testkit.mockAssetId import com.gemwallet.android.testkit.mockWalletId diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/SwapModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/SwapModule.kt new file mode 100644 index 0000000000..502c2fccd3 --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/SwapModule.kt @@ -0,0 +1,89 @@ +package com.gemwallet.android.data.coordinators.di + +import com.gemwallet.android.application.PasswordStore +import com.gemwallet.android.application.swap.coordinators.BuildSwapConfirmParams +import com.gemwallet.android.application.swap.coordinators.GetSwapQuoteData +import com.gemwallet.android.application.swap.coordinators.GetSwapQuotes +import com.gemwallet.android.application.swap.coordinators.GetSwapSupported +import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes +import com.gemwallet.android.application.swap.coordinators.SearchSwapAssets +import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator +import com.gemwallet.android.blockchain.services.SignClientProxy +import com.gemwallet.android.data.coordinators.swap.BuildSwapConfirmParamsImpl +import com.gemwallet.android.data.coordinators.swap.GetSwapQuoteDataImpl +import com.gemwallet.android.data.coordinators.swap.GetSwapQuotesImpl +import com.gemwallet.android.data.coordinators.swap.GetSwapSupportedImpl +import com.gemwallet.android.data.coordinators.swap.RequestSwapQuotesImpl +import com.gemwallet.android.data.coordinators.swap.SearchSwapAssetsImpl +import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.repositories.session.SessionRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import uniffi.gemstone.AlienProvider +import uniffi.gemstone.GemSwapper +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object SwapModule { + + @Singleton + @Provides + fun provideGemSwapper( + alienProvider: AlienProvider, + ): GemSwapper = GemSwapper(alienProvider) + + @Singleton + @Provides + fun provideGetSwapQuotes( + gemSwapper: GemSwapper, + ): GetSwapQuotes = GetSwapQuotesImpl(gemSwapper) + + @Singleton + @Provides + fun provideGetSwapSupported( + gemSwapper: GemSwapper, + ): GetSwapSupported = GetSwapSupportedImpl(gemSwapper) + + @Singleton + @Provides + fun provideGetSwapQuoteData( + gemSwapper: GemSwapper, + signClient: SignClientProxy, + passwordStore: PasswordStore, + loadPrivateKeyOperator: LoadPrivateKeyOperator, + ): GetSwapQuoteData = GetSwapQuoteDataImpl( + gemSwapper = gemSwapper, + signClient = signClient, + passwordStore = passwordStore, + loadPrivateKeyOperator = loadPrivateKeyOperator, + ) + + @Singleton + @Provides + fun provideRequestSwapQuotes( + getSwapQuotes: GetSwapQuotes, + ): RequestSwapQuotes = RequestSwapQuotesImpl(getSwapQuotes) + + @Singleton + @Provides + fun provideBuildSwapConfirmParams( + sessionRepository: SessionRepository, + getSwapQuoteData: GetSwapQuoteData, + ): BuildSwapConfirmParams = BuildSwapConfirmParamsImpl( + sessionRepository = sessionRepository, + getSwapQuoteData = getSwapQuoteData, + ) + + @Singleton + @Provides + fun provideSearchSwapAssets( + assetsRepository: AssetsRepository, + getSwapSupported: GetSwapSupported, + ): SearchSwapAssets = SearchSwapAssetsImpl( + assetsRepository = assetsRepository, + getSwapSupported = getSwapSupported, + ) +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/BuildSwapConfirmParamsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/BuildSwapConfirmParamsImpl.kt new file mode 100644 index 0000000000..0de875a96e --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/BuildSwapConfirmParamsImpl.kt @@ -0,0 +1,55 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.swap.coordinators.BuildSwapConfirmParams +import com.gemwallet.android.application.swap.coordinators.GetSwapQuoteData +import com.gemwallet.android.application.swap.coordinators.SwapNoQuoteException +import com.gemwallet.android.data.repositories.session.SessionRepository +import com.gemwallet.android.model.AssetInfo +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.model.toModel +import kotlinx.coroutines.flow.firstOrNull +import uniffi.gemstone.SwapperQuote +import java.math.BigInteger + +class BuildSwapConfirmParamsImpl( + private val sessionRepository: SessionRepository, + private val getSwapQuoteData: GetSwapQuoteData, +) : BuildSwapConfirmParams { + + override suspend fun invoke( + quote: SwapperQuote, + pay: AssetInfo, + receive: AssetInfo, + ): ConfirmParams.SwapParams? { + val wallet = sessionRepository.session().firstOrNull()?.wallet ?: return null + + val swapData = try { + getSwapQuoteData(quote, wallet) + } catch (_: Throwable) { + throw SwapNoQuoteException() + } + + val from = pay.owner ?: throw SwapNoQuoteException() + return ConfirmParams.SwapParams( + from = from, + fromAsset = pay.asset, + toAsset = receive.asset, + fromAmount = BigInteger(quote.fromValue), + toAmount = BigInteger(quote.toValue), + swapData = swapData.data, + providerId = quote.data.provider.id, + protocol = quote.data.provider.protocol, + providerName = quote.data.provider.name, + protocolId = quote.data.provider.protocolId, + toAddress = swapData.to, + value = swapData.value, + approval = swapData.approval?.toModel(), + gasLimit = swapData.gasLimit?.toBigIntegerOrNull(), + useMaxAmount = quote.request.options.useMaxAmount, + etaInSeconds = quote.etaInSeconds, + slippageBps = quote.data.slippageBps, + memo = swapData.memo, + dataType = swapData.dataType, + ) + } +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt new file mode 100644 index 0000000000..30a49e6c3a --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt @@ -0,0 +1,75 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.PasswordStore +import com.gemwallet.android.application.swap.coordinators.GetSwapQuoteData +import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator +import com.gemwallet.android.blockchain.services.SignClientProxy +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.math.decodeHex +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.Config +import uniffi.gemstone.FetchQuoteData +import uniffi.gemstone.GemSwapQuoteData +import uniffi.gemstone.GemSwapper +import uniffi.gemstone.Permit2Data +import uniffi.gemstone.Permit2Detail +import uniffi.gemstone.PermitSingle +import uniffi.gemstone.SwapperQuote +import uniffi.gemstone.permit2DataToEip712Json +import java.util.Arrays + +class GetSwapQuoteDataImpl( + private val gemSwapper: GemSwapper, + private val signClient: SignClientProxy, + private val passwordStore: PasswordStore, + private val loadPrivateKeyOperator: LoadPrivateKeyOperator, +) : GetSwapQuoteData { + + override suspend fun invoke(quote: SwapperQuote, wallet: Wallet): GemSwapQuoteData { + val permit = gemSwapper.getPermit2ForQuote(quote = quote) + + if (permit == null) { + return gemSwapper.getQuoteData(quote, FetchQuoteData.None) + } + + val permit2Single = permit2Single( + token = permit.token, + spender = permit.spender, + value = permit.value, + nonce = permit.permit2Nonce, + ) + val chain = quote.request.fromAsset.id.toAssetId()?.chain ?: throw Exception() + val permit2Json = permit2DataToEip712Json( + chain = chain.string, + data = permit2Single, + contract = permit.permit2Contract, + ) + val key = loadPrivateKeyOperator.invoke(wallet, chain, passwordStore.getPassword(key = wallet.id)) + val signature = try { + signClient.signTypedMessage( + chain = chain, + input = permit2Json.toByteArray(), + privateKey = key, + ).decodeHex() + } finally { + Arrays.fill(key, 0) + } + val permitData = Permit2Data(permit2Single, signature) + return gemSwapper.getQuoteData(quote, FetchQuoteData.Permit2(permitData)) + } + + private fun permit2Single(token: String, spender: String, value: String, nonce: ULong): PermitSingle { + val config = Config().getSwapConfig() + val now = (System.currentTimeMillis() / 1000).toULong() + return PermitSingle( + details = Permit2Detail( + token = token, + amount = value, + expiration = now + config.permit2Expiration, + nonce = nonce, + ), + spender = spender, + sigDeadline = now + config.permit2SigDeadline, + ) + } +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuotesImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuotesImpl.kt new file mode 100644 index 0000000000..84b203fee0 --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuotesImpl.kt @@ -0,0 +1,53 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.swap.coordinators.GetSwapQuotes +import com.gemwallet.android.domains.asset.chain +import com.gemwallet.android.ext.toIdentifier +import com.wallet.core.primitives.Asset +import uniffi.gemstone.Config +import uniffi.gemstone.GemSwapper +import uniffi.gemstone.SwapperMode +import uniffi.gemstone.SwapperOptions +import uniffi.gemstone.SwapperQuote +import uniffi.gemstone.SwapperQuoteAsset +import uniffi.gemstone.SwapperQuoteRequest +import uniffi.gemstone.getDefaultSlippage +import java.math.BigInteger + +class GetSwapQuotesImpl( + private val gemSwapper: GemSwapper, +) : GetSwapQuotes { + override suspend fun getQuotes( + ownerAddress: String, + destination: String, + from: Asset, + to: Asset, + amount: String, + useMaxAmount: Boolean, + ): List { + val swapRequest = SwapperQuoteRequest( + fromAsset = SwapperQuoteAsset( + id = from.id.toIdentifier(), + symbol = from.symbol, + decimals = from.decimals.toUInt(), + ), + toAsset = SwapperQuoteAsset( + id = to.id.toIdentifier(), + symbol = to.symbol, + decimals = to.decimals.toUInt(), + ), + walletAddress = ownerAddress, + destinationAddress = destination, + value = amount, + mode = SwapperMode.EXACT_IN, + options = SwapperOptions( + slippage = getDefaultSlippage(from.chain.string), + fee = Config().getSwapConfig().referralFee, + preferredProviders = emptyList(), + useMaxAmount = useMaxAmount, + ) + ) + return gemSwapper.getQuote(swapRequest) + .sortedByDescending { BigInteger(it.toValue) } + } +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapSupportedImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapSupportedImpl.kt new file mode 100644 index 0000000000..7d19bb0491 --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapSupportedImpl.kt @@ -0,0 +1,15 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.swap.coordinators.GetSwapSupported +import com.gemwallet.android.ext.toIdentifier +import com.wallet.core.primitives.AssetId +import uniffi.gemstone.GemSwapper +import uniffi.gemstone.SwapperAssetList + +class GetSwapSupportedImpl( + private val gemSwapper: GemSwapper, +) : GetSwapSupported { + override fun getSwapSupportChains(assetId: AssetId): SwapperAssetList { + return gemSwapper.supportedChainsForFromAsset(assetId.toIdentifier()) + } +} diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequester.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt similarity index 67% rename from android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequester.kt rename to android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt index d28dbdc191..7b807a01cf 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequester.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt @@ -1,42 +1,42 @@ -package com.gemwallet.android.features.swap.viewmodels.cases +package com.gemwallet.android.data.coordinators.swap -import com.gemwallet.android.cases.swap.GetSwapQuotes +import com.gemwallet.android.application.swap.coordinators.GetSwapQuotes +import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestParams +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult import com.gemwallet.android.model.Crypto -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestKey -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestParams -import com.gemwallet.android.features.swap.viewmodels.models.QuotesState +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.transformLatest -import javax.inject.Inject +import kotlinx.coroutines.isActive import java.math.BigInteger -class QuoteRequester @Inject constructor( - private val getSwapQuotes: GetSwapQuotes -) { +class RequestSwapQuotesImpl( + private val getSwapQuotes: GetSwapQuotes, +) : RequestSwapQuotes { @OptIn(ExperimentalCoroutinesApi::class) - internal fun requestQuotes( - requestParams: Flow, + override fun invoke( + requestParams: Flow, refreshRequests: Flow, refreshEnabled: Flow, - onFetchStarted: (QuoteRequestKey) -> Unit, - refreshIntervalMillis: Long = QUOTE_REFRESH_INTERVAL_MS, - ): Flow { + onFetchStarted: (SwapQuoteRequestKey) -> Unit, + refreshIntervalMillis: Long, + ): Flow { return requestParams.flatMapLatest { params -> if (params == null) { - return@flatMapLatest flowOf(null) + return@flatMapLatest flowOf(null) } refreshEnabled.flatMapLatest { isEnabled -> @@ -62,7 +62,7 @@ class QuoteRequester @Inject constructor( .flowOn(Dispatchers.IO) } - private suspend fun fetchQuotes(params: QuoteRequestParams): QuotesState = try { + private suspend fun fetchQuotes(params: SwapQuoteRequestParams): SwapQuotesResult = try { val amount = Crypto(params.value, params.pay.asset.decimals).atomicValue val quotes = getSwapQuotes.getQuotes( from = params.pay.asset, @@ -73,14 +73,10 @@ class QuoteRequester @Inject constructor( useMaxAmount = BigInteger(params.pay.balance.balance.available) == amount, ) currentCoroutineContext().ensureActive() - QuotesState(quotes, params.key, params.pay, params.receive) + SwapQuotesResult(quotes, params.key, params.pay, params.receive) } catch (err: CancellationException) { throw err } catch (err: Throwable) { - QuotesState(requestKey = params.key, pay = params.pay, receive = params.receive, err = err) - } - - private companion object { - const val QUOTE_REFRESH_INTERVAL_MS = 30_000L + SwapQuotesResult(requestKey = params.key, pay = params.pay, receive = params.receive, err = err) } } diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt new file mode 100644 index 0000000000..75912ea9a4 --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt @@ -0,0 +1,73 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.swap.coordinators.GetSwapSupported +import com.gemwallet.android.application.swap.coordinators.SearchSwapAssets +import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.domains.swap.SwapItemType +import com.gemwallet.android.ext.isSwapSupport +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.ext.toChain +import com.gemwallet.android.model.AssetInfo +import com.gemwallet.android.model.hasAvailable +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag +import com.wallet.core.primitives.Wallet +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import uniffi.gemstone.SwapperAssetList + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchSwapAssetsImpl( + private val assetsRepository: AssetsRepository, + private val getSwapSupported: GetSwapSupported, +) : SearchSwapAssets { + + override fun invoke( + wallet: Wallet?, + query: String, + swapItemType: SwapItemType, + oppositeAssetId: AssetId?, + tag: AssetTag?, + ): Flow> { + if (wallet == null) { + return emptyFlow() + } + return flow { + if (oppositeAssetId == null) { + val chains = wallet.accounts.map { it.chain }.filter { it.isSwapSupport() } + emit(SwapperAssetList(chains.map { it.string }, emptyList())) + val assetIds = chains + .map { getSwapSupported.getSwapSupportChains(AssetId(it)).assetIds } + .fold(listOf()) { acc, items -> acc + items } + emit(SwapperAssetList(chains.map { it.string }, assetIds)) + } else { + emit(getSwapSupported.getSwapSupportChains(oppositeAssetId)) + } + } + .flatMapLatest { supported -> + assetsRepository.swapSearch( + wallet, + query, + supported.chains.mapNotNull { it.toChain() }, + supported.assetIds.mapNotNull { it.toAssetId() }, + tag?.let { listOf(it) } ?: emptyList(), + ) + } + .catch { emit(emptyList()) } + .map { items -> + items.filter { assetInfo -> + assetInfo.metadata?.isSwapEnabled == true && + if (swapItemType == SwapItemType.Pay) { + assetInfo.balance.balance.hasAvailable() + } else { + true + } + } + } + } +} diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SyncAssetInfoImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SyncAssetInfoImplTest.kt index b9dcc5551e..f5bf9e69af 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SyncAssetInfoImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/SyncAssetInfoImplTest.kt @@ -16,7 +16,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test @@ -87,7 +86,7 @@ class SyncAssetInfoImplTest { } coVerify { assetsRepository.updateBalances(asset.id) } coVerify { assetsRepository.updateAssetMetadata(assetFull) } - verify { streamSubscriptionService.addAssetIds(listOf(asset.id)) } + coVerify { streamSubscriptionService.addAssetIds(listOf(asset.id)) } } @Test @@ -116,6 +115,6 @@ class SyncAssetInfoImplTest { } coVerify { assetsRepository.updateBalances(asset.id) } coVerify { assetsRepository.updateAssetMetadata(assetFull) } - verify { streamSubscriptionService.addAssetIds(listOf(asset.id)) } + coVerify { streamSubscriptionService.addAssetIds(listOf(asset.id)) } } } diff --git a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequesterTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImplTest.kt similarity index 77% rename from android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequesterTest.kt rename to android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImplTest.kt index 1e3cd5abe2..37b402615b 100644 --- a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/cases/QuoteRequesterTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImplTest.kt @@ -1,9 +1,9 @@ -package com.gemwallet.android.features.swap.viewmodels.cases +package com.gemwallet.android.data.coordinators.swap -import com.gemwallet.android.cases.swap.GetSwapQuotes -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestParams -import com.gemwallet.android.features.swap.viewmodels.models.QuotesState -import com.gemwallet.android.features.swap.viewmodels.models.matches +import com.gemwallet.android.application.swap.coordinators.GetSwapQuotes +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestParams +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult +import com.gemwallet.android.application.swap.coordinators.matches import com.gemwallet.android.model.AssetBalance import com.gemwallet.android.model.AssetInfo import com.wallet.core.primitives.Account @@ -29,7 +29,7 @@ import org.junit.Test import uniffi.gemstone.SwapperQuote import java.math.BigDecimal -class QuoteRequesterTest { +class RequestSwapQuotesImplTest { private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) private val refreshEnabled = MutableStateFlow(true) @@ -37,12 +37,12 @@ class QuoteRequesterTest { @Test fun `canceled in flight quote request does not emit an error result`() = runBlocking { val fakeQuotes = StubGetSwapQuotes(delayOnFirst = 5_000) - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) - val results = mutableListOf() + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val results = mutableListOf() val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -61,12 +61,12 @@ class QuoteRequesterTest { @Test fun `invalid input clears quote and late success is ignored`() = runBlocking { val fakeQuotes = StubGetSwapQuotes(nonCancellableOnFirst = true) - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) - val results = mutableListOf() + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val results = mutableListOf() val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -95,23 +95,23 @@ class QuoteRequesterTest { fun `quotes state matches numerically equal request values`() { val pay = assetInfo(symbol = "CAKE", tokenId = "cake") val receive = assetInfo(symbol = "BNB", tokenId = "bnb") - val quotesState = QuotesState( + val quotesState = SwapQuotesResult( requestKey = quoteRequestParams(BigDecimal("1")).key, pay = pay, receive = receive, ) - assertTrue(quotesState.matches(QuoteRequestParams(BigDecimal("1.0"), pay, receive))) + assertTrue(quotesState.matches(SwapQuoteRequestParams(BigDecimal("1.0"), pay, receive))) } @Test fun `successful quote refresh waits for the configured interval`() = runBlocking { val fakeQuotes = StubGetSwapQuotes() - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -131,12 +131,12 @@ class QuoteRequesterTest { @Test fun `quote errors do not schedule automatic retries`() = runBlocking { val fakeQuotes = StubGetSwapQuotes(shouldFail = true) - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) - val results = mutableListOf() + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val results = mutableListOf() val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -157,11 +157,11 @@ class QuoteRequesterTest { @Test fun `automatic refresh stops in background and resumes in foreground`() = runBlocking { val fakeQuotes = StubGetSwapQuotes() - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -185,12 +185,12 @@ class QuoteRequesterTest { @Test fun `null params emits null without calling quotes service`() = runBlocking { val fakeQuotes = StubGetSwapQuotes() - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(null) - val results = mutableListOf() + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(null) + val results = mutableListOf() val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -208,12 +208,12 @@ class QuoteRequesterTest { @Test fun `changing params during debounce does not emit stale result`() = runBlocking { val fakeQuotes = StubGetSwapQuotes() - val requester = QuoteRequester(fakeQuotes) - val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) - val results = mutableListOf() + val requester = RequestSwapQuotesImpl(fakeQuotes) + val requestParams = MutableStateFlow(quoteRequestParams(BigDecimal.ONE)) + val results = mutableListOf() val job = launch { - requester.requestQuotes( + requester.invoke( requestParams = requestParams, refreshRequests = refreshRequests, refreshEnabled = refreshEnabled, @@ -221,17 +221,14 @@ class QuoteRequesterTest { ).collect { results += it } } - // Change params during the 500ms debounce — before first fetch completes delay(200) assertEquals(0, fakeQuotes.requestCount) requestParams.value = quoteRequestParams(BigDecimal("2")) - // Wait for the second request to complete awaitCondition { fakeQuotes.requestCount >= 1 } delay(100) job.cancelAndJoin() - // Only one result — no stale emission from the first cancelled debounce assertEquals(1, results.size) } @@ -261,8 +258,8 @@ class QuoteRequesterTest { ) } - private fun quoteRequestParams(value: BigDecimal): QuoteRequestParams { - return QuoteRequestParams( + private fun quoteRequestParams(value: BigDecimal): SwapQuoteRequestParams { + return SwapQuoteRequestParams( value = value, pay = assetInfo(symbol = "CAKE", tokenId = "cake"), receive = assetInfo(symbol = "BNB", tokenId = "bnb"), diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImplTest.kt new file mode 100644 index 0000000000..4a753dae85 --- /dev/null +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImplTest.kt @@ -0,0 +1,78 @@ +package com.gemwallet.android.data.coordinators.swap + +import com.gemwallet.android.application.swap.coordinators.GetSwapSupported +import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.domains.swap.SwapItemType +import com.gemwallet.android.ext.toIdentifier +import com.gemwallet.android.model.AssetBalance +import com.gemwallet.android.testkit.mockAccount +import com.gemwallet.android.testkit.mockAssetHyperCoreHype +import com.gemwallet.android.testkit.mockAssetHyperCoreUBTC +import com.gemwallet.android.testkit.mockAssetHyperCoreUSDC +import com.gemwallet.android.testkit.mockAssetInfo +import com.gemwallet.android.testkit.mockAssetMetaData +import com.gemwallet.android.testkit.mockWallet +import com.wallet.core.primitives.Chain +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import uniffi.gemstone.SwapperAssetList + +class SearchSwapAssetsImplTest { + + private val wallet = mockWallet(accounts = listOf(mockAccount(chain = Chain.HyperCore))) + private val hypeAsset = mockAssetHyperCoreHype() + private val usdcAsset = mockAssetHyperCoreUSDC() + private val oppositeAsset = mockAssetHyperCoreUBTC() + private val swapableMetaData = mockAssetMetaData(isSwapEnabled = true) + + @Test + fun `pay search excludes assets without available balance`() = runTest { + val fundedAsset = mockAssetInfo( + asset = usdcAsset, + balance = AssetBalance.create(usdcAsset, available = "100000000"), + walletId = wallet.id, + metadata = swapableMetaData, + ) + val stakedOnlyAsset = mockAssetInfo( + asset = hypeAsset, + balance = AssetBalance.create(hypeAsset, available = "0", staked = "500000000"), + walletId = wallet.id, + metadata = swapableMetaData, + ) + + val assetsRepository = mockk { + every { + swapSearch( + wallet = wallet, + query = "", + byChains = listOf(Chain.HyperCore), + byAssets = listOf(hypeAsset.id, usdcAsset.id), + tags = emptyList(), + ) + } returns flowOf(listOf(stakedOnlyAsset, fundedAsset)) + } + val getSwapSupported = mockk { + every { getSwapSupportChains(oppositeAsset.id) } returns SwapperAssetList( + chains = listOf(Chain.HyperCore.string), + assetIds = listOf(hypeAsset.id.toIdentifier(), usdcAsset.id.toIdentifier()), + ) + } + + val subject = SearchSwapAssetsImpl(assetsRepository, getSwapSupported) + + val result = subject.invoke( + wallet = wallet, + query = "", + swapItemType = SwapItemType.Pay, + oppositeAssetId = oppositeAsset.id, + tag = null, + ).first() + + assertEquals(listOf(fundedAsset), result) + } +} diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/SwapModule.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/SwapModule.kt deleted file mode 100644 index bfd8ed41d3..0000000000 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/SwapModule.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.gemwallet.android.data.repositories.di - -import com.gemwallet.android.application.PasswordStore -import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator -import com.gemwallet.android.blockchain.services.SignClientProxy -import com.gemwallet.android.cases.swap.GetSwapQuotes -import com.gemwallet.android.cases.swap.GetSwapSupported -import com.gemwallet.android.data.repositories.swap.SwapRepository -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import uniffi.gemstone.AlienProvider -import uniffi.gemstone.GemSwapper -import javax.inject.Singleton - -@InstallIn(SingletonComponent::class) -@Module -object SwapModule { - - @Singleton - @Provides - fun provideGemSwapper( - alienProvider: AlienProvider, - ) = GemSwapper(alienProvider) - - @Singleton - @Provides - fun provideSwapRepository( - gemSwapper: GemSwapper, - signClient: SignClientProxy, - passwordStore: PasswordStore, - loadPrivateDataOperator: LoadPrivateKeyOperator, - ): SwapRepository = SwapRepository( - gemSwapper = gemSwapper, - signClient = signClient, - passwordStore = passwordStore, - loadPrivateKeyOperator = loadPrivateDataOperator, - ) - - @Singleton - @Provides - fun provideGetSwapSupportCase(repository: SwapRepository): GetSwapSupported = repository - - @Singleton - @Provides - fun provideGetQuotesCase(repository: SwapRepository): GetSwapQuotes = repository -} - diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/swap/SwapRepository.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/swap/SwapRepository.kt deleted file mode 100644 index f711fdd785..0000000000 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/swap/SwapRepository.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.gemwallet.android.data.repositories.swap - -import com.gemwallet.android.application.PasswordStore -import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator -import com.gemwallet.android.blockchain.services.SignClientProxy -import com.gemwallet.android.cases.swap.GetSwapQuotes -import com.gemwallet.android.cases.swap.GetSwapSupported -import com.gemwallet.android.domains.asset.chain -import com.gemwallet.android.ext.toAssetId -import com.gemwallet.android.ext.toIdentifier -import com.gemwallet.android.math.decodeHex -import com.wallet.core.primitives.Asset -import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.Wallet -import uniffi.gemstone.Config -import uniffi.gemstone.FetchQuoteData -import uniffi.gemstone.GemSwapQuoteData -import uniffi.gemstone.GemSwapper -import uniffi.gemstone.Permit2Data -import uniffi.gemstone.Permit2Detail -import uniffi.gemstone.PermitSingle -import uniffi.gemstone.SwapperAssetList -import uniffi.gemstone.SwapperMode -import uniffi.gemstone.SwapperOptions -import uniffi.gemstone.SwapperQuote -import uniffi.gemstone.SwapperQuoteAsset -import uniffi.gemstone.SwapperQuoteRequest -import uniffi.gemstone.getDefaultSlippage -import uniffi.gemstone.permit2DataToEip712Json -import java.math.BigInteger -import java.util.Arrays - -class SwapRepository( - private val gemSwapper: GemSwapper, - private val signClient: SignClientProxy, - private val passwordStore: PasswordStore, - private val loadPrivateKeyOperator: LoadPrivateKeyOperator, -) : GetSwapSupported, GetSwapQuotes { - - override suspend fun getQuotes( - ownerAddress: String, - destination: String, - from: Asset, - to: Asset, amount: String, - useMaxAmount: Boolean, - ): List { - val swapRequest = SwapperQuoteRequest( - fromAsset = SwapperQuoteAsset( - id = from.id.toIdentifier(), - symbol = from.symbol, - decimals = from.decimals.toUInt(), - ), - toAsset = SwapperQuoteAsset( - id = to.id.toIdentifier(), - symbol = to.symbol, - decimals = to.decimals.toUInt(), - ), - walletAddress = ownerAddress, - destinationAddress = destination, - value = amount, - mode = SwapperMode.EXACT_IN, - options = SwapperOptions( - slippage = getDefaultSlippage(from.chain.string), - fee = Config().getSwapConfig().referralFee, - preferredProviders = emptyList(), - useMaxAmount = useMaxAmount, - ) - ) - val quote = gemSwapper.getQuote(swapRequest) - .sortedByDescending { BigInteger(it.toValue) } - return quote - } - - suspend fun getQuoteData(quote: SwapperQuote, wallet: Wallet): GemSwapQuoteData { - val permit = gemSwapper.getPermit2ForQuote(quote = quote) - - if (permit == null) { - return gemSwapper.getQuoteData(quote, FetchQuoteData.None) - } - - val permit2Single = permit2Single( - token = permit.token, - spender = permit.spender, - value = permit.value, - nonce = permit.permit2Nonce - ) - val chain = quote.request.fromAsset.id.toAssetId()?.chain ?: throw Exception() - val permit2Json = permit2DataToEip712Json( - chain = chain.string, - data = permit2Single, - contract = permit.permit2Contract - ) - val key = loadPrivateKeyOperator.invoke(wallet, chain, passwordStore.getPassword(key = wallet.id)) - val signature = try { - signClient.signTypedMessage( - chain = chain, - input = permit2Json.toByteArray(), - privateKey = key, - ).decodeHex() - } finally { - Arrays.fill(key, 0) - } - val permitData = Permit2Data(permit2Single, signature) - return gemSwapper.getQuoteData(quote, FetchQuoteData.Permit2(permitData)) - } - - private fun permit2Single(token: String, spender: String, value: String, nonce: ULong): PermitSingle { - return PermitSingle( - details = Permit2Detail( - token = token, - amount = value, - expiration = (System.currentTimeMillis() / 1000 + 60 * 60 * 30).toULong(), - nonce = nonce - ), - spender = spender, - sigDeadline = (System.currentTimeMillis() / 1000 + 60 * 30).toULong(), - ) - } - - override fun getSwapSupportChains(assetId: AssetId): SwapperAssetList { - return gemSwapper.supportedChainsForFromAsset(assetId.toIdentifier()) - } -} diff --git a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScene.kt b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScene.kt index d330627ac0..78e32ad724 100644 --- a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScene.kt +++ b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScene.kt @@ -23,7 +23,7 @@ import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.list_item.sectionHeaderItem import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.components.swap.SwapDetailsSummaryItem -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.features.swap.viewmodels.models.SwapUiState import com.gemwallet.android.features.swap.views.components.SwapAction import com.gemwallet.android.features.swap.views.components.SwapError diff --git a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScreen.kt b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScreen.kt index bb5b6ab20b..62d735bdb5 100644 --- a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScreen.kt +++ b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapScreen.kt @@ -10,7 +10,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.features.swap.viewmodels.SwapViewModel -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.features.swap.views.dialogs.PriceImpactWarningDialog import com.gemwallet.android.ui.ObserveStartedState import com.gemwallet.android.ui.components.swap.SwapDetailsBottomSheet diff --git a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt index 32d6ae8184..21ec70863d 100644 --- a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt +++ b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt @@ -15,7 +15,7 @@ import com.gemwallet.android.features.asset_select.presents.views.AssetSelectSce import com.gemwallet.android.features.asset_select.presents.views.RecentsSheetHost import com.gemwallet.android.features.asset_select.viewmodels.RecentsSheetViewModel import com.gemwallet.android.features.swap.viewmodels.SwapSelectViewModel -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.domains.swap.SwapItemType import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.AssetSubtype import kotlinx.collections.immutable.toImmutableList diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt index 42d723f353..32045695d8 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt @@ -7,39 +7,28 @@ import com.gemwallet.android.application.asset_select.coordinators.GetRecentAsse import com.gemwallet.android.application.asset_select.coordinators.SwitchAssetVisibility import com.gemwallet.android.application.asset_select.coordinators.ToggleAssetPin import com.gemwallet.android.application.asset_select.coordinators.UpdateRecentAsset -import com.gemwallet.android.model.AssetFilter import com.gemwallet.android.application.session.coordinators.GetSession -import com.gemwallet.android.cases.swap.GetSwapSupported -import com.gemwallet.android.model.hasAvailable +import com.gemwallet.android.application.swap.coordinators.SearchSwapAssets import com.gemwallet.android.cases.tokens.SearchTokensCase -import com.gemwallet.android.data.repositories.assets.AssetsRepository -import com.gemwallet.android.ext.isSwapSupport +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.ext.toAssetId -import com.gemwallet.android.ext.toChain -import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.features.asset_select.viewmodels.BaseAssetSelectViewModel import com.gemwallet.android.features.asset_select.viewmodels.models.SelectAssetFilters import com.gemwallet.android.features.asset_select.viewmodels.models.SelectSearch -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType +import com.gemwallet.android.model.AssetFilter +import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.ui.models.navigation.RouteArgument import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.AssetTag -import com.wallet.core.primitives.Wallet import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import uniffi.gemstone.SwapperAssetList import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -50,9 +39,8 @@ class SwapSelectViewModel @Inject constructor( updateRecentAsset: UpdateRecentAsset, switchAssetVisibility: SwitchAssetVisibility, toggleAssetPin: ToggleAssetPin, - assetsRepository: AssetsRepository, searchTokensCase: SearchTokensCase, - getSwapSupported: GetSwapSupported, + searchSwapAssets: SearchSwapAssets, savedStateHandle: SavedStateHandle, ) : BaseAssetSelectViewModel( getSession = getSession, @@ -61,7 +49,7 @@ class SwapSelectViewModel @Inject constructor( switchAssetVisibility = switchAssetVisibility, toggleAssetPin = toggleAssetPin, searchTokensCase = searchTokensCase, - search = SwapSelectSearch(assetsRepository, getSwapSupported), + search = SwapSelectSearch(searchSwapAssets), ) { val payAssetId = savedStateHandle.getStateFlow(RouteArgument.FromAssetId.key, null) @@ -96,8 +84,7 @@ private fun SavedStateHandle.requireSwapItemType(): SwapItemType = @OptIn(ExperimentalCoroutinesApi::class) class SwapSelectSearch( - private val assetsRepository: AssetsRepository, - private val getSwapSupported: GetSwapSupported, + private val searchSwapAssets: SearchSwapAssets, ) : SelectSearch { val swapItemType = MutableStateFlow(null) @@ -105,63 +92,33 @@ class SwapSelectSearch( val receiveId = MutableStateFlow(null) override fun items(filters: Flow): Flow> { - return combine(filters, swapItemType, payId, receiveId) { params/*session, query, type, payId, receiveId, tag*/ -> - val filters: SelectAssetFilters? = params[0] as? SelectAssetFilters? - val type: SwapItemType? = params[1] as? SwapItemType? - val payId: AssetId? = params[2] as? AssetId? - val receiveId: AssetId? = params[3] as? AssetId? - val oppositeId = getOppositeAssetId(type, payId, receiveId) - SearchParams(filters?.session?.wallet, filters?.query ?: "", oppositeId, filters?.tag) - } - .flatMapLatest { params -> - if (params.wallet == null) { - return@flatMapLatest emptyFlow() - } - - flow { - if (params.oppositeAssetId == null) { - val chains = params.wallet.accounts.map { it.chain }.filter { it.isSwapSupport() } - emit(SwapperAssetList(chains.map { it.string }, emptyList())) - val assetIds = chains - .map { getSwapSupported.getSwapSupportChains(AssetId(it)).assetIds } - .fold(listOf()) { acc, items -> acc + items } - emit(SwapperAssetList(chains.map { it.string }, assetIds)) - } else { - emit(getSwapSupported.getSwapSupportChains(params.oppositeAssetId)) - } - - }.flatMapLatest { supported -> - assetsRepository.swapSearch( - params.wallet, - params.query, - supported.chains.mapNotNull { item -> item.toChain() }, - supported.assetIds.mapNotNull { it.toAssetId() }, - params.tag?.let { listOf(params.tag) } ?: emptyList(), - ) - }.catch { emit(emptyList()) } + return combine(filters, swapItemType, payId, receiveId) { filter, type, payId, receiveId -> + SearchInputs( + filter = filter, + type = type, + oppositeAssetId = getOppositeAssetId(type, payId, receiveId), + ) } - .map { items -> - items.filter { assetInfo -> - assetInfo.metadata?.isSwapEnabled == true - && if (swapItemType.value == SwapItemType.Pay) { - assetInfo.balance.balance.hasAvailable() - } else { - true - } - } + .flatMapLatest { inputs -> + searchSwapAssets( + wallet = inputs.filter?.session?.wallet, + query = inputs.filter?.query ?: "", + swapItemType = inputs.type ?: SwapItemType.Receive, + oppositeAssetId = inputs.oppositeAssetId, + tag = inputs.filter?.tag, + ) } } - private fun getOppositeAssetId(type: SwapItemType?, payId: AssetId?, receiveId: AssetId?) = when (type) { + private fun getOppositeAssetId(type: SwapItemType?, payId: AssetId?, receiveId: AssetId?) = when (type) { SwapItemType.Pay -> receiveId SwapItemType.Receive -> payId null -> null } - private class SearchParams( - val wallet: Wallet?, - val query: String, + private data class SearchInputs( + val filter: SelectAssetFilters?, + val type: SwapItemType?, val oppositeAssetId: AssetId?, - val tag: AssetTag? ) } diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt index ad8015edab..756653adc8 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt @@ -8,37 +8,39 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.EnableAsset +import com.gemwallet.android.application.swap.coordinators.BuildSwapConfirmParams +import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes +import com.gemwallet.android.application.swap.coordinators.SwapNoQuoteException +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestParams +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult +import com.gemwallet.android.application.swap.coordinators.create +import com.gemwallet.android.application.swap.coordinators.getQuote +import com.gemwallet.android.application.swap.coordinators.matches import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository -import com.gemwallet.android.data.repositories.swap.SwapRepository import com.gemwallet.android.domains.asset.calculateFiat import com.gemwallet.android.domains.asset.formatFiat +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.ext.getAccount import com.gemwallet.android.ext.toAssetId import com.gemwallet.android.ext.toIdentifier -import com.gemwallet.android.math.parseNumberOrNull -import com.gemwallet.android.features.swap.viewmodels.cases.QuoteRequester import com.gemwallet.android.features.swap.viewmodels.models.QuoteUiState -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestKey import com.gemwallet.android.features.swap.viewmodels.models.QuoteState -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestParams -import com.gemwallet.android.features.swap.viewmodels.models.QuotesState import com.gemwallet.android.features.swap.viewmodels.models.SwapActionState import com.gemwallet.android.features.swap.viewmodels.models.SwapError -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType import com.gemwallet.android.features.swap.viewmodels.models.SwapUiState import com.gemwallet.android.features.swap.viewmodels.models.TransferDataUiState import com.gemwallet.android.features.swap.viewmodels.models.TransferQuoteSnapshot import com.gemwallet.android.features.swap.viewmodels.models.create import com.gemwallet.android.features.swap.viewmodels.models.createSwapUiState import com.gemwallet.android.features.swap.viewmodels.models.formattedToAmount -import com.gemwallet.android.features.swap.viewmodels.models.getQuote -import com.gemwallet.android.features.swap.viewmodels.models.receiveEquivalent import com.gemwallet.android.features.swap.viewmodels.models.matches +import com.gemwallet.android.features.swap.viewmodels.models.receiveEquivalent import com.gemwallet.android.features.swap.viewmodels.models.toError +import com.gemwallet.android.math.parseNumberOrNull import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.format -import com.gemwallet.android.model.toModel import com.gemwallet.android.ui.models.navigation.RouteArgument import com.gemwallet.android.ui.models.swap.SwapDetailsUIModelFactory import com.gemwallet.android.ui.models.swap.SwapDetailsUIModelInput @@ -54,17 +56,16 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uniffi.gemstone.SwapperProvider import java.math.BigDecimal -import java.math.BigInteger import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -73,9 +74,9 @@ class SwapViewModel @Inject constructor( private val sessionRepository: SessionRepository, private val assetsRepository: AssetsRepository, private val enableAsset: EnableAsset, - private val swapRepository: SwapRepository, - quoteRequester: QuoteRequester, - private val savedStateHandle: SavedStateHandle + private val buildSwapConfirmParams: BuildSwapConfirmParams, + requestSwapQuotes: RequestSwapQuotes, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private val quoteUiState = MutableStateFlow(QuoteUiState.NoInput) @@ -120,12 +121,12 @@ class SwapViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, "") private val quoteRequestParams = combine(payValueFlow, payAsset, receiveAsset) { value, fromAsset, toAsset -> - QuoteRequestParams.create(value, fromAsset, toAsset) + SwapQuoteRequestParams.create(value, fromAsset, toAsset) } .onEach(::onQuoteRequestParamsChanged) .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val quoteResults = quoteRequester.requestQuotes( + private val quoteResults = requestSwapQuotes( requestParams = quoteRequestParams, refreshRequests = refreshRequests, refreshEnabled = quoteRefreshEnabled, @@ -319,7 +320,11 @@ class SwapViewModel @Inject constructor( ) try { - val params = swap(snapshot) ?: run { + val params = buildSwapConfirmParams( + quote = snapshot.quote.quote, + pay = snapshot.quote.pay, + receive = snapshot.quote.receive, + ) ?: run { if (transferDataUiState.value.matches(snapshot)) { clearTransferQuoteState() } @@ -335,12 +340,12 @@ class SwapViewModel @Inject constructor( pauseQuoteRefreshUntilNextStart.value = true clearTransferQuoteState(resumeQuoteRefresh = false) } - } catch (err: SwapError) { + } catch (_: SwapNoQuoteException) { if (transferDataUiState.value.matches(snapshot)) { transferDataUiState.value = TransferDataUiState.Error( quoteKey = snapshot.requestKey, providerId = snapshot.providerId, - error = err, + error = SwapError.NoQuote, ) } } catch (err: Throwable) { @@ -354,47 +359,13 @@ class SwapViewModel @Inject constructor( } } - private suspend fun swap(snapshot: TransferQuoteSnapshot): ConfirmParams? { - val quote = snapshot.quote - val fromAmount = BigInteger(quote.quote.fromValue) - val wallet = sessionRepository.session().firstOrNull()?.wallet ?: return null - - val swapData = try { - swapRepository.getQuoteData(quote.quote, wallet) - } catch (_: Throwable) { - throw SwapError.NoQuote - } - - return ConfirmParams.SwapParams( - from = quote.pay.owner ?: throw SwapError.NoQuote, - fromAsset = quote.pay.asset, - toAsset = quote.receive.asset, - fromAmount = fromAmount, - toAmount = BigInteger(quote.quote.toValue), - swapData = swapData.data, - providerId = quote.quote.data.provider.id, - protocol = quote.quote.data.provider.protocol, - providerName = quote.quote.data.provider.name, - protocolId = quote.quote.data.provider.protocolId, - toAddress = swapData.to, - value = swapData.value, - approval = swapData.approval?.toModel(), - gasLimit = swapData.gasLimit?.toBigIntegerOrNull(), - useMaxAmount = quote.quote.request.options.useMaxAmount, - etaInSeconds = quote.quote.etaInSeconds, - slippageBps = quote.quote.data.slippageBps, - memo = swapData.memo, - dataType = swapData.dataType, - ) - } - private fun updateBalance(id: AssetId) = viewModelScope.launch(Dispatchers.IO) { val session = sessionRepository.session().firstOrNull() ?: return@launch session.wallet.getAccount(id.chain) ?: return@launch enableAsset(session.wallet.id, id) } - private fun onQuoteRequestParamsChanged(params: QuoteRequestParams?) { + private fun onQuoteRequestParamsChanged(params: SwapQuoteRequestParams?) { if (params == null) { selectedProvider.update { null } clearTransferQuoteState() @@ -406,20 +377,21 @@ class SwapViewModel @Inject constructor( quoteUiState.value = QuoteUiState.Loading(params.key) } - private fun onQuoteFetchStarted(requestKey: QuoteRequestKey) { + private fun onQuoteFetchStarted(requestKey: SwapQuoteRequestKey) { if (transferDataUiState.value is TransferDataUiState.Loading || pauseQuoteRefreshUntilNextStart.value) { return } quoteUiState.value = QuoteUiState.Loading(requestKey) } - private fun onQuoteResults(results: QuotesState?) { + private fun onQuoteResults(results: SwapQuotesResult?) { if (results == null || transferDataUiState.value is TransferDataUiState.Loading || pauseQuoteRefreshUntilNextStart.value) { return } + val err = results.err quoteUiState.value = when { - results.err != null -> QuoteUiState.Error(results.requestKey, SwapError.toError(results.err)) + err != null -> QuoteUiState.Error(results.requestKey, SwapError.toError(err)) results.items.isEmpty() -> QuoteUiState.Error(results.requestKey, SwapError.NoQuote) else -> QuoteUiState.Ready(results) } diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteUiState.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteUiState.kt index d69f5aba27..58cf658058 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteUiState.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteUiState.kt @@ -1,13 +1,16 @@ package com.gemwallet.android.features.swap.viewmodels.models +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult + internal sealed interface QuoteUiState { data object NoInput : QuoteUiState - data class Loading(val requestKey: QuoteRequestKey) : QuoteUiState - data class Ready(val quotes: QuotesState) : QuoteUiState - data class Error(val requestKey: QuoteRequestKey, val error: SwapError) : QuoteUiState + data class Loading(val requestKey: SwapQuoteRequestKey) : QuoteUiState + data class Ready(val quotes: SwapQuotesResult) : QuoteUiState + data class Error(val requestKey: SwapQuoteRequestKey, val error: SwapError) : QuoteUiState } -internal val QuoteUiState.requestKey: QuoteRequestKey? +internal val QuoteUiState.requestKey: SwapQuoteRequestKey? get() = when (this) { QuoteUiState.NoInput -> null is QuoteUiState.Loading -> requestKey diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapUiState.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapUiState.kt index 37d26c8b5c..6de792df88 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapUiState.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapUiState.kt @@ -1,5 +1,8 @@ package com.gemwallet.android.features.swap.viewmodels.models +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult +import com.gemwallet.android.application.swap.coordinators.getQuote import uniffi.gemstone.SwapperProvider sealed interface SwapActionState { @@ -60,11 +63,11 @@ data class SwapUiState( } internal data class TransferQuoteSnapshot( - val quotes: QuotesState, + val quotes: SwapQuotesResult, val selectedProvider: SwapperProvider?, val quote: QuoteState, ) { - val requestKey: QuoteRequestKey + val requestKey: SwapQuoteRequestKey get() = quotes.requestKey val providerId: SwapperProvider @@ -74,7 +77,7 @@ internal data class TransferQuoteSnapshot( } internal fun TransferQuoteSnapshot.Companion.create( - quotes: QuotesState, + quotes: SwapQuotesResult, selectedProvider: SwapperProvider?, ): TransferQuoteSnapshot? { val quote = quotes.getQuote(selectedProvider)?.let { QuoteState(it, quotes.pay, quotes.receive) } ?: return null diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/TransferDataUiState.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/TransferDataUiState.kt index c8c35b0251..5cbb3b9361 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/TransferDataUiState.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/TransferDataUiState.kt @@ -1,16 +1,17 @@ package com.gemwallet.android.features.swap.viewmodels.models +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey import uniffi.gemstone.SwapperProvider internal sealed interface TransferDataUiState { data object Idle : TransferDataUiState data class Loading( - val quoteKey: QuoteRequestKey, + val quoteKey: SwapQuoteRequestKey, val providerId: SwapperProvider, ) : TransferDataUiState data class Error( - val quoteKey: QuoteRequestKey, + val quoteKey: SwapQuoteRequestKey, val providerId: SwapperProvider, val error: SwapError, ) : TransferDataUiState diff --git a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectSearchTest.kt b/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectSearchTest.kt index 28cc92949c..471f104c8f 100644 --- a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectSearchTest.kt +++ b/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectSearchTest.kt @@ -1,71 +1,48 @@ package com.gemwallet.android.features.swap.viewmodels -import com.gemwallet.android.cases.swap.GetSwapSupported +import com.gemwallet.android.application.swap.coordinators.SearchSwapAssets +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.features.asset_select.viewmodels.models.SelectAssetFilters -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType import com.gemwallet.android.model.AssetBalance +import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.testkit.mockAccount -import com.gemwallet.android.testkit.mockAssetHyperCoreHype import com.gemwallet.android.testkit.mockAssetHyperCoreUBTC import com.gemwallet.android.testkit.mockAssetHyperCoreUSDC import com.gemwallet.android.testkit.mockAssetInfo import com.gemwallet.android.testkit.mockAssetMetaData import com.gemwallet.android.testkit.mockSession import com.gemwallet.android.testkit.mockWallet -import com.gemwallet.android.ext.toIdentifier +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag import com.wallet.core.primitives.Chain -import io.mockk.every -import io.mockk.mockk +import com.wallet.core.primitives.Wallet +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test -import uniffi.gemstone.SwapperAssetList class SwapSelectSearchTest { private val wallet = mockWallet(accounts = listOf(mockAccount(chain = Chain.HyperCore))) - private val hypeAsset = mockAssetHyperCoreHype() private val usdcAsset = mockAssetHyperCoreUSDC() private val oppositeAsset = mockAssetHyperCoreUBTC() private val swapableMetaData = mockAssetMetaData(isSwapEnabled = true) @Test - fun `pay search excludes assets without available balance`() = runTest { + fun `pay search forwards opposite receive id and selected type to coordinator`() = runTest { val fundedAsset = mockAssetInfo( asset = usdcAsset, balance = AssetBalance.create(usdcAsset, available = "100000000"), walletId = wallet.id, metadata = swapableMetaData, ) - val stakedOnlyAsset = mockAssetInfo( - asset = hypeAsset, - balance = AssetBalance.create(hypeAsset, available = "0", staked = "500000000"), - walletId = wallet.id, - metadata = swapableMetaData, - ) - - val assetsRepository = mockk { - every { - swapSearch( - wallet = wallet, - query = "", - byChains = listOf(Chain.HyperCore), - byAssets = listOf(hypeAsset.id, usdcAsset.id), - tags = emptyList(), - ) - } returns flowOf(listOf(stakedOnlyAsset, fundedAsset)) - } - val getSwapSupported = mockk { - every { getSwapSupportChains(oppositeAsset.id) } returns SwapperAssetList( - chains = listOf(Chain.HyperCore.string), - assetIds = listOf(hypeAsset.id.toIdentifier(), usdcAsset.id.toIdentifier()), - ) - } + val captured = mutableListOf() + val searchSwapAssets = recordingSearchSwapAssets(captured) { flowOf(listOf(fundedAsset)) } - val subject = SwapSelectSearch(assetsRepository, getSwapSupported).apply { + val subject = SwapSelectSearch(searchSwapAssets).apply { swapItemType.value = SwapItemType.Pay receiveId.value = oppositeAsset.id } @@ -82,5 +59,55 @@ class SwapSelectSearchTest { val result = subject.items(filters).first() assertEquals(listOf(fundedAsset), result) + val call = captured.last() + assertEquals(wallet, call.wallet) + assertEquals(SwapItemType.Pay, call.swapItemType) + assertEquals(oppositeAsset.id, call.oppositeAssetId) + } + + @Test + fun `null swap item type defaults to receive`() = runTest { + val captured = mutableListOf() + val searchSwapAssets = recordingSearchSwapAssets(captured) { flowOf(emptyList()) } + + val subject = SwapSelectSearch(searchSwapAssets) + val filters = MutableStateFlow( + SelectAssetFilters( + session = mockSession(wallet = wallet), + query = "", + chainFilter = emptyList(), + hasBalance = false, + tag = null, + ) + ) + + subject.items(filters).first() + + assertEquals(SwapItemType.Receive, captured.last().swapItemType) + } + + private fun recordingSearchSwapAssets( + sink: MutableList, + result: (SearchCall) -> Flow>, + ) = object : SearchSwapAssets { + override fun invoke( + wallet: Wallet?, + query: String, + swapItemType: SwapItemType, + oppositeAssetId: AssetId?, + tag: AssetTag?, + ): Flow> { + val call = SearchCall(wallet, query, swapItemType, oppositeAssetId, tag) + sink += call + return result(call) + } } + + private data class SearchCall( + val wallet: Wallet?, + val query: String, + val swapItemType: SwapItemType, + val oppositeAssetId: AssetId?, + val tag: AssetTag?, + ) } diff --git a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModelTest.kt b/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModelTest.kt index 98f5c225e7..80f3a8fc7c 100644 --- a/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModelTest.kt +++ b/android/features/swap/viewmodels/src/test/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModelTest.kt @@ -4,18 +4,20 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshots.Snapshot import androidx.lifecycle.SavedStateHandle import com.gemwallet.android.application.assets.coordinators.EnableAsset +import com.gemwallet.android.application.swap.coordinators.BuildSwapConfirmParams +import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes +import com.gemwallet.android.application.swap.coordinators.SwapNoQuoteException +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey +import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestParams +import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository -import com.gemwallet.android.data.repositories.swap.SwapRepository +import com.gemwallet.android.domains.swap.SwapItemType import com.gemwallet.android.ext.toIdentifier -import com.gemwallet.android.features.swap.viewmodels.cases.QuoteRequester -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestKey -import com.gemwallet.android.features.swap.viewmodels.models.QuoteRequestParams -import com.gemwallet.android.features.swap.viewmodels.models.QuotesState import com.gemwallet.android.features.swap.viewmodels.models.SwapActionState import com.gemwallet.android.features.swap.viewmodels.models.SwapError -import com.gemwallet.android.features.swap.viewmodels.models.SwapItemType import com.gemwallet.android.model.AssetBalance +import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Session import com.gemwallet.android.testkit.mockAccount @@ -57,7 +59,6 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import uniffi.gemstone.GemSwapQuoteData import uniffi.gemstone.GemSwapQuoteDataType import uniffi.gemstone.SwapperMode import uniffi.gemstone.SwapperOptions @@ -97,20 +98,18 @@ class SwapViewModelTest { every { getAssetInfo(usdcAsset.id) } returns flowOf(usdcInfo) } private val enableAsset = mockk(relaxed = true) - private val swapRepository = mockk(relaxed = true) - private val quoteRequester = mockk(relaxed = true) { - every { requestQuotes(any(), any(), any(), any(), any()) } returns emptyFlow() - } + private val buildSwapConfirmParams = mockk(relaxed = true) + private val requestSwapQuotes = mockk(relaxed = true) @Before fun setUp() { Dispatchers.setMain(testDispatcher) mockkObject(SwapDetailsUIModelFactory) - clearMocks(sessionRepository, assetsRepository, swapRepository, quoteRequester) + clearMocks(sessionRepository, assetsRepository, buildSwapConfirmParams, requestSwapQuotes) every { sessionRepository.session() } returns MutableStateFlow(null) every { assetsRepository.getAssetInfo(solAsset.id) } returns flowOf(solInfo) every { assetsRepository.getAssetInfo(usdcAsset.id) } returns flowOf(usdcInfo) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns emptyFlow() + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns emptyFlow() every { SwapDetailsUIModelFactory.create(any()) } returns mockk(relaxed = true) } @@ -124,8 +123,8 @@ class SwapViewModelTest { sessionRepository = sessionRepository, assetsRepository = assetsRepository, enableAsset = enableAsset, - swapRepository = swapRepository, - quoteRequester = quoteRequester, + buildSwapConfirmParams = buildSwapConfirmParams, + requestSwapQuotes = requestSwapQuotes, savedStateHandle = savedStateHandle, ) @@ -218,19 +217,16 @@ class SwapViewModelTest { @Test fun `quote refresh does not replace swapping state`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( Session(wallet = wallet, currency = Currency.USD) ) - val quoteDataGate = CompletableDeferred() - coEvery { swapRepository.getQuoteData(any(), any()) } coAnswers { - quoteDataGate.await() - mockQuoteData() - } + val confirmParamsGate = CompletableDeferred() + stubBuildConfirmParams { confirmParamsGate.await() } val savedState = swapSavedState() @@ -252,20 +248,20 @@ class SwapViewModelTest { assertEquals("2500000", viewModel.quote.value?.quote?.toValue) assertEquals(0, confirmCalls) - quoteDataGate.complete(Unit) + confirmParamsGate.complete(Unit) awaitCondition { confirmCalls == 1 } } @Test fun `transfer data error keeps quote visible and routes retry through transfer state`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( Session(wallet = wallet, currency = Currency.USD) ) - coEvery { swapRepository.getQuoteData(any(), any()) } throws IllegalStateException("boom") + coEvery { buildSwapConfirmParams(any(), any(), any()) } throws SwapNoQuoteException() val viewModel = createViewModel( swapSavedState() @@ -284,14 +280,14 @@ class SwapViewModelTest { @Test fun `quote changing actions clear transfer error state`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( Session(wallet = wallet, currency = Currency.USD) ) - coEvery { swapRepository.getQuoteData(any(), any()) } throws IllegalStateException("boom") + coEvery { buildSwapConfirmParams(any(), any(), any()) } throws SwapNoQuoteException() val viewModel = createViewModel( swapSavedState() @@ -317,10 +313,10 @@ class SwapViewModelTest { @Test fun `quote refresh stays paused after confirm handoff until screen restarts`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) + val quotesFlow = MutableSharedFlow(replay = 1) val refreshEnabledFlow = slot>() every { - quoteRequester.requestQuotes(any(), any(), capture(refreshEnabledFlow), any(), any()) + requestSwapQuotes.invoke(any(), any(), capture(refreshEnabledFlow), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) @@ -328,11 +324,8 @@ class SwapViewModelTest { Session(wallet = wallet, currency = Currency.USD) ) - val quoteDataGate = CompletableDeferred() - coEvery { swapRepository.getQuoteData(any(), any()) } coAnswers { - quoteDataGate.await() - mockQuoteData() - } + val confirmParamsGate = CompletableDeferred() + stubBuildConfirmParams { confirmParamsGate.await() } val viewModel = createViewModel( swapSavedState() @@ -350,7 +343,7 @@ class SwapViewModelTest { advanceUntilIdle() viewModel.swap {} awaitCondition { viewModel.uiState.value.action == SwapActionState.TransferLoading } - quoteDataGate.complete(Unit) + confirmParamsGate.complete(Unit) awaitCondition { viewModel.uiState.value.action == SwapActionState.Ready } advanceUntilIdle() assertEquals(false, refreshStates.last()) @@ -369,10 +362,10 @@ class SwapViewModelTest { @Test fun `quote fetch started callback shows quote loading for refreshes`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - val onFetchStarted = slot<(QuoteRequestKey) -> Unit>() + val quotesFlow = MutableSharedFlow(replay = 1) + val onFetchStarted = slot<(SwapQuoteRequestKey) -> Unit>() every { - quoteRequester.requestQuotes(any(), any(), any(), capture(onFetchStarted), any()) + requestSwapQuotes.invoke(any(), any(), any(), capture(onFetchStarted), any()) } returns quotesFlow val viewModel = createViewModel( @@ -391,18 +384,15 @@ class SwapViewModelTest { @Test fun `confirm callback runs before transfer loading clears`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( Session(wallet = wallet, currency = Currency.USD) ) - val quoteDataGate = CompletableDeferred() - coEvery { swapRepository.getQuoteData(any(), any()) } coAnswers { - quoteDataGate.await() - mockQuoteData() - } + val confirmParamsGate = CompletableDeferred() + stubBuildConfirmParams { confirmParamsGate.await() } val viewModel = createViewModel( swapSavedState() @@ -416,7 +406,7 @@ class SwapViewModelTest { wasTransferLoadingOnConfirm = viewModel.uiState.value.isTransferLoading } awaitCondition { viewModel.uiState.value.isTransferLoading } - quoteDataGate.complete(Unit) + confirmParamsGate.complete(Unit) awaitCondition { !viewModel.uiState.value.isTransferLoading } assertTrue(wasTransferLoadingOnConfirm) @@ -425,18 +415,15 @@ class SwapViewModelTest { @Test fun `confirm params keep frozen from amount while transfer is in flight`() = runTest(testDispatcher) { - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( Session(wallet = wallet, currency = Currency.USD) ) - val quoteDataGate = CompletableDeferred() - coEvery { swapRepository.getQuoteData(any(), any()) } coAnswers { - quoteDataGate.await() - mockQuoteData() - } + val confirmParamsGate = CompletableDeferred() + stubBuildConfirmParams { confirmParamsGate.await() } val viewModel = createViewModel( swapSavedState() @@ -452,7 +439,7 @@ class SwapViewModelTest { awaitCondition { viewModel.uiState.value.isTransferLoading } viewModel.payValue.setTextAndPlaceCursorAtEnd("2") - quoteDataGate.complete(Unit) + confirmParamsGate.complete(Unit) awaitCondition { confirmParams != null } assertEquals(BigInteger("1000000000"), confirmParams?.fromAmount) @@ -477,8 +464,8 @@ class SwapViewModelTest { slippageText = "0.5%", ) - val quotesFlow = MutableSharedFlow(replay = 1) - every { quoteRequester.requestQuotes(any(), any(), any(), any(), any()) } returns quotesFlow + val quotesFlow = MutableSharedFlow(replay = 1) + every { requestSwapQuotes.invoke(any(), any(), any(), any(), any()) } returns quotesFlow val wallet = mockWallet(accounts = listOf(mockAccount(chain = solAsset.id.chain))) every { sessionRepository.session() } returns MutableStateFlow( @@ -486,10 +473,7 @@ class SwapViewModelTest { ) var swapCalls = 0 - coEvery { swapRepository.getQuoteData(any(), any()) } coAnswers { - swapCalls += 1 - mockQuoteData() - } + stubBuildConfirmParams { swapCalls += 1 } val viewModel = createViewModel( swapSavedState() @@ -512,6 +496,36 @@ class SwapViewModelTest { assertEquals(SwapActionState.Ready, viewModel.uiState.value.action) } + private fun stubBuildConfirmParams(beforeReturn: suspend () -> Unit = {}) { + coEvery { buildSwapConfirmParams(any(), any(), any()) } coAnswers { + beforeReturn() + val quote = firstArg() + val pay = secondArg() + val receive = thirdArg() + ConfirmParams.SwapParams( + from = pay.owner!!, + fromAsset = pay.asset, + toAsset = receive.asset, + fromAmount = BigInteger(quote.fromValue), + toAmount = BigInteger(quote.toValue), + swapData = "0x", + providerId = quote.data.provider.id, + protocol = quote.data.provider.protocol, + providerName = quote.data.provider.name, + protocolId = quote.data.provider.protocolId, + toAddress = "0xconfirm", + value = "0", + approval = null, + gasLimit = BigInteger("210000"), + useMaxAmount = quote.request.options.useMaxAmount, + etaInSeconds = quote.etaInSeconds, + slippageBps = quote.data.slippageBps, + memo = null, + dataType = GemSwapQuoteDataType.CONTRACT, + ) + } + } + private fun awaitCondition(timeoutMs: Long = 2_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs while (!condition() && System.currentTimeMillis() < deadline) { @@ -523,16 +537,16 @@ class SwapViewModelTest { private suspend fun seedReadyQuote( viewModel: SwapViewModel, - quotesFlow: MutableSharedFlow, + quotesFlow: MutableSharedFlow, quote: SwapperQuote = mockQuote(), - ): QuotesState { + ): SwapQuotesResult { viewModel.payValue.setTextAndPlaceCursorAtEnd("1") Snapshot.sendApplyNotifications() awaitCondition { viewModel.uiState.value.action == SwapActionState.QuoteLoading } - val quotesState = QuotesState( + val quotesState = SwapQuotesResult( items = listOf(quote), - requestKey = QuoteRequestParams(BigDecimal.ONE, solInfo, usdcInfo).key, + requestKey = SwapQuoteRequestParams(BigDecimal.ONE, solInfo, usdcInfo).key, pay = solInfo, receive = usdcInfo, ) @@ -592,14 +606,4 @@ class SwapViewModelTest { ), etaInSeconds = 30u, ) - - private fun mockQuoteData() = GemSwapQuoteData( - to = "0xconfirm", - dataType = GemSwapQuoteDataType.CONTRACT, - value = "0", - data = "0x", - memo = null, - approval = null, - gasLimit = "210000", - ) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/BuildSwapConfirmParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/BuildSwapConfirmParams.kt new file mode 100644 index 0000000000..41d6a26f03 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/BuildSwapConfirmParams.kt @@ -0,0 +1,15 @@ +package com.gemwallet.android.application.swap.coordinators + +import com.gemwallet.android.model.AssetInfo +import com.gemwallet.android.model.ConfirmParams +import uniffi.gemstone.SwapperQuote + +interface BuildSwapConfirmParams { + suspend operator fun invoke( + quote: SwapperQuote, + pay: AssetInfo, + receive: AssetInfo, + ): ConfirmParams.SwapParams? +} + +class SwapNoQuoteException(cause: Throwable? = null) : Exception(cause) diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuoteData.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuoteData.kt new file mode 100644 index 0000000000..5a97412cbe --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuoteData.kt @@ -0,0 +1,9 @@ +package com.gemwallet.android.application.swap.coordinators + +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.GemSwapQuoteData +import uniffi.gemstone.SwapperQuote + +interface GetSwapQuoteData { + suspend operator fun invoke(quote: SwapperQuote, wallet: Wallet): GemSwapQuoteData +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapQuotes.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuotes.kt similarity index 83% rename from android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapQuotes.kt rename to android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuotes.kt index 7525ba2ac0..5e9b205f02 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapQuotes.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapQuotes.kt @@ -1,4 +1,4 @@ -package com.gemwallet.android.cases.swap +package com.gemwallet.android.application.swap.coordinators import com.wallet.core.primitives.Asset import uniffi.gemstone.SwapperQuote @@ -12,4 +12,4 @@ interface GetSwapQuotes { amount: String, useMaxAmount: Boolean, ): List -} \ No newline at end of file +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapSupported.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapSupported.kt similarity index 74% rename from android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapSupported.kt rename to android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapSupported.kt index e6ff6686d4..4a8016654f 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/cases/swap/GetSwapSupported.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/GetSwapSupported.kt @@ -1,8 +1,8 @@ -package com.gemwallet.android.cases.swap +package com.gemwallet.android.application.swap.coordinators import com.wallet.core.primitives.AssetId import uniffi.gemstone.SwapperAssetList interface GetSwapSupported { fun getSwapSupportChains(assetId: AssetId): SwapperAssetList -} \ No newline at end of file +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt new file mode 100644 index 0000000000..463656477e --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt @@ -0,0 +1,17 @@ +package com.gemwallet.android.application.swap.coordinators + +import kotlinx.coroutines.flow.Flow + +interface RequestSwapQuotes { + operator fun invoke( + requestParams: Flow, + refreshRequests: Flow, + refreshEnabled: Flow, + onFetchStarted: (SwapQuoteRequestKey) -> Unit, + refreshIntervalMillis: Long = QUOTE_REFRESH_INTERVAL_MS, + ): Flow + + companion object { + const val QUOTE_REFRESH_INTERVAL_MS = 30_000L + } +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SearchSwapAssets.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SearchSwapAssets.kt new file mode 100644 index 0000000000..ec793c6efb --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SearchSwapAssets.kt @@ -0,0 +1,18 @@ +package com.gemwallet.android.application.swap.coordinators + +import com.gemwallet.android.domains.swap.SwapItemType +import com.gemwallet.android.model.AssetInfo +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.AssetTag +import com.wallet.core.primitives.Wallet +import kotlinx.coroutines.flow.Flow + +interface SearchSwapAssets { + operator fun invoke( + wallet: Wallet?, + query: String, + swapItemType: SwapItemType, + oppositeAssetId: AssetId?, + tag: AssetTag?, + ): Flow> +} diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteRequestParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuoteRequestParams.kt similarity index 69% rename from android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteRequestParams.kt rename to android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuoteRequestParams.kt index c8dfd7209f..fc7a1d75d5 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuoteRequestParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuoteRequestParams.kt @@ -1,21 +1,21 @@ -package com.gemwallet.android.features.swap.viewmodels.models +package com.gemwallet.android.application.swap.coordinators import com.gemwallet.android.model.AssetInfo import com.wallet.core.primitives.AssetId import java.math.BigDecimal -internal data class QuoteRequestParams( +data class SwapQuoteRequestParams( val value: BigDecimal, val pay: AssetInfo, val receive: AssetInfo, ) { - val key: QuoteRequestKey - get() = QuoteRequestKey(value, pay.id(), receive.id()) + val key: SwapQuoteRequestKey + get() = SwapQuoteRequestKey(value, pay.id(), receive.id()) companion object } -internal class QuoteRequestKey( +class SwapQuoteRequestKey( val value: BigDecimal, val payAssetId: AssetId, val receiveAssetId: AssetId, @@ -24,7 +24,7 @@ internal class QuoteRequestKey( if (this === other) { return true } - if (other !is QuoteRequestKey) { + if (other !is SwapQuoteRequestKey) { return false } @@ -41,10 +41,10 @@ internal class QuoteRequestKey( } } -internal fun QuoteRequestParams.Companion.create(value: BigDecimal, pay: AssetInfo?, receive: AssetInfo?): QuoteRequestParams? { +fun SwapQuoteRequestParams.Companion.create(value: BigDecimal, pay: AssetInfo?, receive: AssetInfo?): SwapQuoteRequestParams? { return if (pay == null || receive == null || pay.id() == receive.id() || value.compareTo(BigDecimal.ZERO) == 0) { null } else { - QuoteRequestParams(value, pay, receive) + SwapQuoteRequestParams(value, pay, receive) } } diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuotesState.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuotesResult.kt similarity index 57% rename from android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuotesState.kt rename to android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuotesResult.kt index c8c7caeab6..1062c20caa 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/QuotesState.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/SwapQuotesResult.kt @@ -1,19 +1,19 @@ -package com.gemwallet.android.features.swap.viewmodels.models +package com.gemwallet.android.application.swap.coordinators import com.gemwallet.android.model.AssetInfo import uniffi.gemstone.SwapperProvider import uniffi.gemstone.SwapperQuote -internal data class QuotesState( +data class SwapQuotesResult( val items: List = emptyList(), - val requestKey: QuoteRequestKey, + val requestKey: SwapQuoteRequestKey, val pay: AssetInfo, val receive: AssetInfo, val err: Throwable? = null, ) -internal fun QuotesState.getQuote(provider: SwapperProvider?): SwapperQuote? = +fun SwapQuotesResult.getQuote(provider: SwapperProvider?): SwapperQuote? = items.firstOrNull { it.data.provider.id == provider } ?: items.firstOrNull() -internal fun QuotesState.matches(params: QuoteRequestParams?): Boolean = +fun SwapQuotesResult.matches(params: SwapQuoteRequestParams?): Boolean = params?.key == requestKey diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapItemType.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/swap/SwapItemType.kt similarity index 62% rename from android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapItemType.kt rename to android/gemcore/src/main/kotlin/com/gemwallet/android/domains/swap/SwapItemType.kt index d17c573ff4..d7a9a5d9c8 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/models/SwapItemType.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/swap/SwapItemType.kt @@ -1,4 +1,4 @@ -package com.gemwallet.android.features.swap.viewmodels.models +package com.gemwallet.android.domains.swap import kotlinx.serialization.Serializable @@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable enum class SwapItemType { Pay, Receive, -} \ No newline at end of file +} From be052ff761a6bacb72826d1e6ba9e78655a8a5ee Mon Sep 17 00:00:00 2001 From: Radmir Date: Fri, 8 May 2026 15:52:18 +0500 Subject: [PATCH 2/2] Improve swap quote timing, error handling and search Add a small Time utility (nowSeconds) and use it in permit creation; validate asset chain with a clear error message. Introduce QUOTE_DEBOUNCE_MS constant and use it for quote request debounce instead of a hardcoded 500ms. Simplify asset ID collection by using flatMap and avoid intermediate folding. Consolidate transfer error handling in SwapViewModel via a setTransferError helper to remove duplicated code. Also add a small cache placeholder file. --- .cache/cache1 | 1 + .../coordinators/swap/GetSwapQuoteDataImpl.kt | 7 +++-- .../swap/RequestSwapQuotesImpl.kt | 3 ++- .../coordinators/swap/SearchSwapAssetsImpl.kt | 9 +++---- .../features/swap/viewmodels/SwapViewModel.kt | 26 +++++++++---------- .../swap/coordinators/RequestSwapQuotes.kt | 1 + .../kotlin/com/gemwallet/android/ext/Time.kt | 3 +++ 7 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 .cache/cache1 create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Time.kt diff --git a/.cache/cache1 b/.cache/cache1 new file mode 100644 index 0000000000..00a55f0496 --- /dev/null +++ b/.cache/cache1 @@ -0,0 +1 @@ +[[]] \ No newline at end of file diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt index 30a49e6c3a..6706caafe1 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/GetSwapQuoteDataImpl.kt @@ -4,6 +4,7 @@ import com.gemwallet.android.application.PasswordStore import com.gemwallet.android.application.swap.coordinators.GetSwapQuoteData import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator import com.gemwallet.android.blockchain.services.SignClientProxy +import com.gemwallet.android.ext.nowSeconds import com.gemwallet.android.ext.toAssetId import com.gemwallet.android.math.decodeHex import com.wallet.core.primitives.Wallet @@ -38,7 +39,9 @@ class GetSwapQuoteDataImpl( value = permit.value, nonce = permit.permit2Nonce, ) - val chain = quote.request.fromAsset.id.toAssetId()?.chain ?: throw Exception() + val chain = checkNotNull(quote.request.fromAsset.id.toAssetId()?.chain) { + "Swap quote has invalid asset id" + } val permit2Json = permit2DataToEip712Json( chain = chain.string, data = permit2Single, @@ -60,7 +63,7 @@ class GetSwapQuoteDataImpl( private fun permit2Single(token: String, spender: String, value: String, nonce: ULong): PermitSingle { val config = Config().getSwapConfig() - val now = (System.currentTimeMillis() / 1000).toULong() + val now = nowSeconds() return PermitSingle( details = Permit2Detail( token = token, diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt index d1e0d98a9b..5089a6e1e8 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/RequestSwapQuotesImpl.kt @@ -2,6 +2,7 @@ package com.gemwallet.android.data.coordinators.swap import com.gemwallet.android.application.swap.coordinators.GetSwapQuotes import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes +import com.gemwallet.android.application.swap.coordinators.RequestSwapQuotes.Companion.QUOTE_DEBOUNCE_MS import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestKey import com.gemwallet.android.application.swap.coordinators.SwapQuoteRequestParams import com.gemwallet.android.application.swap.coordinators.SwapQuotesResult @@ -47,7 +48,7 @@ class RequestSwapQuotesImpl( merge(flowOf(Unit), refreshRequests) .transformLatest { while (currentCoroutineContext().isActive) { - delay(500) + delay(QUOTE_DEBOUNCE_MS) onFetchStarted(params.key) val data = fetchQuotes(params) emit(data) diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt index 75912ea9a4..b6c1a6a485 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/swap/SearchSwapAssetsImpl.kt @@ -40,11 +40,10 @@ class SearchSwapAssetsImpl( return flow { if (oppositeAssetId == null) { val chains = wallet.accounts.map { it.chain }.filter { it.isSwapSupport() } - emit(SwapperAssetList(chains.map { it.string }, emptyList())) - val assetIds = chains - .map { getSwapSupported.getSwapSupportChains(AssetId(it)).assetIds } - .fold(listOf()) { acc, items -> acc + items } - emit(SwapperAssetList(chains.map { it.string }, assetIds)) + val chainNames = chains.map { it.string } + emit(SwapperAssetList(chainNames, emptyList())) + val assetIds = chains.flatMap { getSwapSupported.getSwapSupportChains(AssetId(it)).assetIds } + emit(SwapperAssetList(chainNames, assetIds)) } else { emit(getSwapSupported.getSwapSupportChains(oppositeAssetId)) } diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt index fb6565d270..4235c3f016 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapViewModel.kt @@ -320,6 +320,16 @@ class SwapViewModel @Inject constructor( providerId = snapshot.providerId, ) + fun setTransferError(error: SwapError) { + if (transferDataUiState.value.matches(snapshot)) { + transferDataUiState.value = TransferDataUiState.Error( + quoteKey = snapshot.requestKey, + providerId = snapshot.providerId, + error = error, + ) + } + } + try { val params = buildSwapConfirmParams( quote = snapshot.quote.quote, @@ -342,21 +352,9 @@ class SwapViewModel @Inject constructor( clearTransferQuoteState(resumeQuoteRefresh = false) } } catch (_: SwapNoQuoteException) { - if (transferDataUiState.value.matches(snapshot)) { - transferDataUiState.value = TransferDataUiState.Error( - quoteKey = snapshot.requestKey, - providerId = snapshot.providerId, - error = SwapError.NoQuote, - ) - } + setTransferError(SwapError.NoQuote) } catch (err: Throwable) { - if (transferDataUiState.value.matches(snapshot)) { - transferDataUiState.value = TransferDataUiState.Error( - quoteKey = snapshot.requestKey, - providerId = snapshot.providerId, - error = SwapError.Unknown(err.message ?: ""), - ) - } + setTransferError(SwapError.Unknown(err.message ?: "")) } } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt index 463656477e..a470cae1d9 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/swap/coordinators/RequestSwapQuotes.kt @@ -13,5 +13,6 @@ interface RequestSwapQuotes { companion object { const val QUOTE_REFRESH_INTERVAL_MS = 30_000L + const val QUOTE_DEBOUNCE_MS = 500L } } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Time.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Time.kt new file mode 100644 index 0000000000..4a326c9495 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Time.kt @@ -0,0 +1,3 @@ +package com.gemwallet.android.ext + +fun nowSeconds(): ULong = (System.currentTimeMillis() / 1000).toULong()