diff --git a/apps/flipcash/features/contact-verification/build.gradle.kts b/apps/flipcash/features/contact-verification/build.gradle.kts index 4dfcb93d3..548916e38 100644 --- a/apps/flipcash/features/contact-verification/build.gradle.kts +++ b/apps/flipcash/features/contact-verification/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:phone")) implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index 3b7aa8018..20305c8ad 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.onramp.BuyOptionsCache import com.flipcash.app.phone.CountryLocale import com.flipcash.app.phone.PhoneUtils import com.flipcash.features.contact.verification.R @@ -46,6 +47,7 @@ internal class PhoneVerificationViewModel @Inject constructor( private val featureFlags: FeatureFlagController, private val resources: ResourceHelper, private val dispatchers: DispatcherProvider, + private val buyOptionsCache: BuyOptionsCache, ) : BaseViewModel( initialState = State(selectedLocale = phoneUtils.defaultCountryLocale), updateStateForEvent = updateStateForEvent, @@ -182,6 +184,9 @@ internal class PhoneVerificationViewModel @Inject constructor( viewModelScope.launch { profileController.updateUserProfile() } + viewModelScope.launch { + buyOptionsCache.prefetchForCurrentUser() + } viewModelScope.launch { delay(1.seconds) diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt index b994678ce..7624a3396 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.contact.verification.internal.phone import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.onramp.BuyOptionsCache import com.flipcash.app.phone.PhoneUtils import com.flipcash.features.contact.verification.R import com.flipcash.services.controllers.ContactVerificationController @@ -39,6 +40,7 @@ class PhoneVerificationViewModelErrorTest { private val userManager = mockk(relaxed = true) private val featureFlags = mockk(relaxed = true) private val resources = mockk(relaxed = true) + private val buyOptionsCache = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @@ -70,6 +72,7 @@ class PhoneVerificationViewModelErrorTest { featureFlags = featureFlags, resources = resources, dispatchers = dispatchers, + buyOptionsCache = buyOptionsCache, ) } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsCache.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsCache.kt new file mode 100644 index 000000000..932060fbf --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsCache.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.onramp + +import com.coinbase.onramp.api.CoinbaseApi +import com.flipcash.services.user.UserManager +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BuyOptionsCache @Inject constructor( + private val api: CoinbaseApi, + private val jwtExecutor: CoinbaseJwtExecutor, + private val userManager: UserManager, +) { + private val cache = mutableMapOf>() + private val mutex = Mutex() + + fun getCached(region: PhoneRegion): Set? = cache[region.cacheKey] + + suspend fun prefetchForCurrentUser(): Set? { + val phone = userManager.profile?.verifiedPhoneNumber ?: return null + val region = regionFromPhone(phone) ?: return null + return prefetch(region) + } + + fun isUsdfAvailable(region: PhoneRegion): Boolean { + val mints = cache[region.cacheKey] ?: return true // default to true on cache miss + return BuyOptionsMint.USDF in mints + } + + suspend fun prefetch(region: PhoneRegion): Set? { + cache[region.cacheKey]?.let { return it } + + return mutex.withLock { + // double-check after acquiring lock + cache[region.cacheKey]?.let { return it } + + requestAndCache(region) + } + } + + private suspend fun requestAndCache(region: PhoneRegion): Set? { + val host = "api.developer.coinbase.com/" + val path = "onramp/v1/buy/options" + val response = jwtExecutor.execute( + scheme = "https", + host = host, + path = path, + method = "GET", + ) { jwt -> + runCatching { + api.getBuyOptions( + url = "https://$host$path", + jwt = "Bearer $jwt", + country = region.country, + subdivision = region.subdivision, + ) + } + }.getOrNull() ?: return null + + val mints = parseMints(response) + cache[region.cacheKey] = mints + return mints + } + + private fun parseMints(response: JsonObject): Set { + return response["purchase_currencies"] + ?.jsonArray + ?.mapNotNull { element -> + element.jsonObject["symbol"]?.jsonPrimitive?.content?.let { BuyOptionsMint(it) } + } + ?.toSet() + ?: emptySet() + } +} diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsMint.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsMint.kt new file mode 100644 index 000000000..939f16411 --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsMint.kt @@ -0,0 +1,8 @@ +package com.flipcash.app.onramp + +@JvmInline +value class BuyOptionsMint(val symbol: String) { + companion object { + val USDF = BuyOptionsMint("USDF") + } +} diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseJwtExecutor.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseJwtExecutor.kt new file mode 100644 index 000000000..c2a527efa --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseJwtExecutor.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.onramp + +import com.flipcash.services.models.GetJwtError +import com.flipcash.shared.onramp.coinbase.BuildConfig +import com.getcode.network.jwt.ApiProvider +import com.getcode.network.jwt.Jwt +import com.getcode.network.jwt.JwtSecuredEndpoint +import com.getcode.utils.trace +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CoinbaseJwtExecutor @Inject constructor( + private val jwtProvider: OnRampJwtProvider, +) { + suspend fun execute( + scheme: String, + host: String, + path: String, + method: String, + call: suspend (Jwt) -> Result, + ): Result { + val apiKey = BuildConfig.COINBASE_ONRAMP_API_KEY + return jwtProvider.provideJwtForEndpoint( + apiKey = apiKey, + endpoint = JwtSecuredEndpoint( + provider = ApiProvider.Coinbase, + scheme = scheme, + host = host, + path = path, + method = method, + ), + ).fold( + onSuccess = { call(it) }, + onFailure = { error -> + trace( + message = "JWT request failed", + tag = "OnRamp", + metadata = { + "endpoint" to "$method $host$path" + "errorType" to error::class.simpleName.orEmpty() + }, + error = error, + ) + when (error) { + is GetJwtError.EmailVerificationRequired -> Result.failure( + OnRampAuthError.VerificationRequired(email = true) + ) + is GetJwtError.PhoneVerificationRequired -> Result.failure( + OnRampAuthError.VerificationRequired(phone = true) + ) + else -> Result.failure(error) + } + } + ) + } +} diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index ef5be1611..d431dbbdf 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -8,12 +8,8 @@ import com.coinbase.onramp.data.OnRampPurchaseRequest import com.coinbase.onramp.data.OnRampPurchaseResponse import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.services.models.GetJwtError import com.flipcash.services.user.UserManager -import com.flipcash.shared.onramp.coinbase.BuildConfig -import com.getcode.network.jwt.ApiProvider import com.getcode.network.jwt.Jwt -import com.getcode.network.jwt.JwtSecuredEndpoint import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat @@ -45,17 +41,13 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonIgnoreUnknownKeys -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import retrofit2.HttpException import javax.inject.Inject sealed class PurchaseGate : Throwable() { data class WebViewWarning(val channel: WebViewChannel) : PurchaseGate() - data object GooglePayNotSupported : PurchaseGate() - data object GooglePayNoPaymentMethod : PurchaseGate() + class GooglePayNotSupported : PurchaseGate() + class GooglePayNoPaymentMethod : PurchaseGate() } typealias OrderWithPaymentLink = Pair @@ -67,7 +59,7 @@ private val json = Json { @ActivityRetainedScoped class CoinbaseOnRampController @Inject constructor( - private val jwtProvider: OnRampJwtProvider, + private val jwtExecutor: CoinbaseJwtExecutor, private val onRampApiEndpoint: OnRampApiConfig, private val api: CoinbaseApi, private val userManager: UserManager, @@ -76,6 +68,7 @@ class CoinbaseOnRampController @Inject constructor( private val transactionController: TransactionOperations, private val googlePayReadiness: GooglePayReadiness, private val webViewChannelDetector: WebViewChannelDetector, + private val buyOptionsCache: BuyOptionsCache, ) { private val _state = MutableStateFlow(CoinbaseOnRampState.Idle) @@ -115,8 +108,8 @@ class CoinbaseOnRampController @Inject constructor( suspend fun checkPurchaseGates(): Result { when (googlePayReadiness.check()) { - GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported) - GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod) + GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported()) + GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod()) GooglePayReadiness.Status.Ready -> Unit } @@ -132,70 +125,50 @@ class CoinbaseOnRampController @Inject constructor( return Result.success(Unit) } - private val buyOptionsCache: MutableMap = mutableMapOf() - /** * Resolves the on-ramp token based on the user's phone number region. - * Calls the Coinbase buy-options API to check if USDF is tradable in the - * user's detected region. Falls back to USDC when USDF is unavailable. * - * Results are cached per region so the API is only called once per - * country+subdivision combination. + * When [allowUsdcFallback] is true (deposit flow), falls back to USDC when + * USDF is unavailable in the user's region. When false (launchpad token + * purchases), always returns USDF — there is no program to support a + * USDC swap for arbitrary tokens yet. */ - suspend fun resolveOnRampToken(): Token { + suspend fun resolveOnRampToken(allowUsdcFallback: Boolean = false): Token { + if (!allowUsdcFallback) return Token.usdf + val phone = userManager.profile?.verifiedPhoneNumber ?: return Token.usdf val region = regionFromPhone(phone) ?: return Token.usdf - val usdfAvailable = buyOptionsCache.getOrPut(region.cacheKey) { - checkBuyOptions(country = region.country, subdivision = region.subdivision) - .map { response -> isUsdfTradable(response) } - .getOrDefault(true) // default to USDF on API failure + // Fast path: check if already cached + val cached = buyOptionsCache.getCached(region) + if (cached != null) { + return if (BuyOptionsMint.USDF in cached) Token.usdf else Token.usdc } - return if (usdfAvailable) Token.usdf else Token.usdc - } - - private fun isUsdfTradable(response: JsonObject): Boolean { - return response["purchase_currencies"] - ?.jsonArray - ?.any { it.jsonObject["symbol"]?.jsonPrimitive?.content == "USDF" } - ?: false - } - - suspend fun checkBuyOptions( - country: String? = null, - subdivision: String? = null, - ): Result { - return requestJwtAndExecute( - scheme = "https", - host = "api.developer.coinbase.com/", - path = "onramp/v1/buy/options", - method = "GET", - call = { jwt -> - runCatching { - api.getBuyOptions( - url = "https://api.developer.coinbase.com/onramp/v1/buy/options", - jwt = "Bearer $jwt", - country = country, - subdivision = subdivision, - ) - } - } - ) + // Slow path: fetch and cache; default to USDF on failure + val mints = buyOptionsCache.prefetch(region) ?: return Token.usdf + return if (BuyOptionsMint.USDF in mints) Token.usdf else Token.usdc } suspend fun placeOrderAndStartPayment( token: Token, verifiedFiat: VerifiedFiat, ): Result { - return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount, token) + val resolvedToken = if (token.address == Mint.usdf) { + resolveOnRampToken(allowUsdcFallback = true) + } else { + token + } + + return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount, resolvedToken) .mapCatching { (orderId, paymentLink) -> val order = OnrampOrder(orderId, paymentLink.url) - if (token.address == Mint.usdf) { + if (resolvedToken.address == Mint.usdf || resolvedToken.address == Mint.usdc) { // USDF goes to the deposit address — server auto-detects it. - // No stateful swap needed. - startPayment(order, token, verifiedFiat, null) + // USDC goes to the owner's ATA — UsdcDepositSweep converts it. + // Neither requires a stateful swap. + startPayment(order, resolvedToken, verifiedFiat, null) } else { val owner = userManager.accountCluster ?: throw IllegalStateException("No account cluster") @@ -208,7 +181,7 @@ class CoinbaseOnRampController @Inject constructor( fund = { Result.success(Unit) } ).getOrThrow() - startPayment(order, token, verifiedFiat, swapId) + startPayment(order, resolvedToken, verifiedFiat, swapId) } } } @@ -252,7 +225,8 @@ class CoinbaseOnRampController @Inject constructor( paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, email = email, phoneNumber = phone, - destinationAddress = destination + destinationAddress = destination, + purchaseCurrency = token.symbol.uppercase(), ) return requestJwtAndPlaceOrder(order, onRampApiEndpoint) @@ -297,7 +271,8 @@ class CoinbaseOnRampController @Inject constructor( paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, email = email, phoneNumber = phone, - destinationAddress = destination + destinationAddress = destination, + purchaseCurrency = token.symbol.uppercase(), ) return requestJwtAndPlaceOrder(order, onRampApiEndpoint) @@ -340,11 +315,13 @@ class CoinbaseOnRampController @Inject constructor( } private fun destinationForToken(owner: AccountCluster, token: Token): String { - return if (token.address == Mint.usdf) { - owner.depositAddressFor(token).base58() - } else { - val swapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) - swapAccounts.pda.publicKey.base58() + return when (token.address) { + Mint.usdf -> owner.depositAddressFor(token).base58() + Mint.usdc -> owner.authorityPublicKey.base58() + else -> { + val swapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) + swapAccounts.pda.publicKey.base58() + } } } @@ -353,48 +330,8 @@ class CoinbaseOnRampController @Inject constructor( host: String, path: String, method: String, - call: suspend (Jwt) -> Result - ): Result { - val apiKey = BuildConfig.COINBASE_ONRAMP_API_KEY - return jwtProvider.provideJwtForEndpoint( - apiKey = apiKey, - endpoint = JwtSecuredEndpoint( - provider = ApiProvider.Coinbase, - scheme = scheme, - host = host, - path = path, - method = method, - ), - ).fold( - onSuccess = { call(it) }, - onFailure = { error -> - trace( - message = "JWT request failed", - tag = "OnRamp", - metadata = { - "endpoint" to "$method $host$path" - "errorType" to error::class.simpleName.orEmpty() - }, - error = error, - ) - when (error) { - is GetJwtError.EmailVerificationRequired -> Result.failure( - OnRampAuthError.VerificationRequired( - email = true - ) - ) - - is GetJwtError.PhoneVerificationRequired -> Result.failure( - OnRampAuthError.VerificationRequired( - phone = true - ) - ) - - else -> Result.failure(error) - } - } - ) - } + call: suspend (Jwt) -> Result, + ): Result = jwtExecutor.execute(scheme, host, path, method, call) private suspend fun requestJwtAndPlaceOrder( order: OnRampPurchaseRequest, diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/BuyOptionsCacheTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/BuyOptionsCacheTest.kt new file mode 100644 index 000000000..4a0d90fe2 --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/BuyOptionsCacheTest.kt @@ -0,0 +1,202 @@ +package com.flipcash.app.onramp + +import com.coinbase.onramp.api.CoinbaseApi +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BuyOptionsCacheTest { + + private val api = mockk(relaxed = true) + private val jwtExecutor = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + + private lateinit var cache: BuyOptionsCache + + @Before + fun setUp() { + cache = BuyOptionsCache(api, jwtExecutor, userManager) + } + + private fun buyOptionsResponse(vararg symbols: String): JsonObject = JsonObject( + mapOf( + "purchase_currencies" to JsonArray( + symbols.map { JsonObject(mapOf("symbol" to JsonPrimitive(it))) } + ) + ) + ) + + private fun stubApi(response: JsonObject) { + coEvery { + jwtExecutor.execute(any(), any(), any(), any(), any Result>()) + } coAnswers { + val call = arg Result>(4) + call("test-jwt") + } + coEvery { + api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = any()) + } returns response + coEvery { + api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = isNull()) + } returns response + } + + @Test + fun `parseMints extracts correct symbols from response`() = runTest { + val response = buyOptionsResponse("USDC", "USDF", "BTC") + stubApi(response) + + val region = PhoneRegion("US") + val mints = cache.prefetch(region) + + assertEquals( + setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF"), BuyOptionsMint("BTC")), + mints + ) + } + + @Test + fun `second call returns cached result without hitting API`() = runTest { + val response = buyOptionsResponse("USDC", "USDF") + stubApi(response) + + val region = PhoneRegion("US") + cache.prefetch(region) + cache.prefetch(region) + + coVerify(exactly = 1) { api.getBuyOptions(any(), any(), any(), any()) } + } + + @Test + fun `getCached returns null before prefetch`() { + val region = PhoneRegion("US") + assertNull(cache.getCached(region)) + } + + @Test + fun `getCached returns mints after prefetch`() = runTest { + val response = buyOptionsResponse("USDC", "USDF") + stubApi(response) + + val region = PhoneRegion("US") + cache.prefetch(region) + + assertEquals( + setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF")), + cache.getCached(region) + ) + } + + @Test + fun `isUsdfAvailable returns true when USDF in cache`() = runTest { + val response = buyOptionsResponse("USDC", "USDF") + stubApi(response) + + val region = PhoneRegion("US") + cache.prefetch(region) + + assertTrue(cache.isUsdfAvailable(region)) + } + + @Test + fun `isUsdfAvailable returns false when USDF not in cache`() = runTest { + val response = buyOptionsResponse("USDC", "BTC") + stubApi(response) + + val region = PhoneRegion("US") + cache.prefetch(region) + + assertEquals(false, cache.isUsdfAvailable(region)) + } + + @Test + fun `isUsdfAvailable defaults to true on cache miss`() { + val region = PhoneRegion("US") + assertTrue(cache.isUsdfAvailable(region)) + } + + @Test + fun `prefetchForCurrentUser no-ops when no verified phone`() = runTest { + every { userManager.profile } returns null + + val result = cache.prefetchForCurrentUser() + assertNull(result) + coVerify(exactly = 0) { api.getBuyOptions(any(), any(), any(), any()) } + } + + @Test + fun `prefetchForCurrentUser no-ops when phone has no verified number`() = runTest { + every { userManager.profile } returns UserProfile( + displayName = "Test", + socialAccounts = emptyList(), + verifiedEmailAddress = "test@test.com", + verifiedPhoneNumber = null, + ) + + val result = cache.prefetchForCurrentUser() + assertNull(result) + coVerify(exactly = 0) { api.getBuyOptions(any(), any(), any(), any()) } + } + + @Test + fun `prefetchForCurrentUser fetches for detected region`() = runTest { + every { userManager.profile } returns UserProfile( + displayName = "Test", + socialAccounts = emptyList(), + verifiedEmailAddress = "test@test.com", + verifiedPhoneNumber = "+14155551234", + ) + + val response = buyOptionsResponse("USDC", "USDF") + stubApi(response) + + val result = cache.prefetchForCurrentUser() + assertEquals(setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF")), result) + } + + @Test + fun `prefetch returns null on JWT failure`() = runTest { + coEvery { + jwtExecutor.execute(any(), any(), any(), any(), any()) + } returns Result.failure(RuntimeException("jwt fail")) + + val region = PhoneRegion("US") + val result = cache.prefetch(region) + assertNull(result) + } + + @Test + fun `prefetch returns null on API failure`() = runTest { + coEvery { + jwtExecutor.execute(any(), any(), any(), any(), any Result>()) + } coAnswers { + val call = arg Result>(4) + call("test-jwt") + } + coEvery { api.getBuyOptions(any(), any(), any(), any()) } throws RuntimeException("api fail") + coEvery { api.getBuyOptions(any(), any(), any(), subdivision = isNull()) } throws RuntimeException("api fail") + + val region = PhoneRegion("US") + val result = cache.prefetch(region) + assertNull(result) + } +} diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt index 747e0acf7..871318334 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt @@ -13,18 +13,15 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdc import com.getcode.opencode.model.financial.usdf +import com.getcode.solana.keys.Mint import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.slot import io.mockk.unmockkStatic import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Before import org.junit.Test @@ -40,13 +37,14 @@ import kotlin.test.assertTrue @Config(manifest = Config.NONE) class CoinbaseOnRampControllerTest { - private val jwtProvider = mockk(relaxed = true) + private val jwtExecutor = mockk(relaxed = true) private val api = mockk(relaxed = true) private val userManager = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val featureFlags = mockk(relaxed = true) private val googlePayReadiness = mockk(relaxed = true) private val webViewChannelDetector = mockk(relaxed = true) + private val buyOptionsCache = mockk(relaxed = true) private val onRampApiEndpoint = OnRampApiConfig( scheme = "https", @@ -65,9 +63,11 @@ class CoinbaseOnRampControllerTest { mockkStatic("com.getcode.opencode.internal.solana.extensions.TokenKt") val fakeUsdf = mockk(relaxed = true) { every { symbol } returns "USDF" + every { address } returns Mint.usdf } val fakeUsdc = mockk(relaxed = true) { every { symbol } returns "USDC" + every { address } returns Mint.usdc } every { Token.usdf } returns fakeUsdf every { Token.usdc } returns fakeUsdc @@ -77,7 +77,7 @@ class CoinbaseOnRampControllerTest { every { webViewChannelDetector.detect() } returns null controller = CoinbaseOnRampController( - jwtProvider = jwtProvider, + jwtExecutor = jwtExecutor, onRampApiEndpoint = onRampApiEndpoint, api = api, userManager = userManager, @@ -86,6 +86,7 @@ class CoinbaseOnRampControllerTest { transactionController = mockk(relaxed = true), googlePayReadiness = googlePayReadiness, webViewChannelDetector = webViewChannelDetector, + buyOptionsCache = buyOptionsCache, ) } @@ -263,160 +264,123 @@ class CoinbaseOnRampControllerTest { // endregion - // region checkBuyOptions + // region placeOrderAndStartPayment token resolution @Test - fun `checkBuyOptions passes country and subdivision to API`() = runTest { - val urlSlot = slot() - val countrySlot = slot() - val subdivisionSlot = slot() - - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions( - url = capture(urlSlot), - jwt = any(), - country = capture(countrySlot), - subdivision = capture(subdivisionSlot), - ) - } returns JsonObject(emptyMap()) + fun `placeOrderAndStartPayment resolves on-ramp token for USDF deposits`() = runTest { + stubAccountCluster() + stubProfile(phone = "+14155551234") // valid US number for region resolution + stubBuyOptionsCacheMints(mintsWithUsdf) - val result = controller.checkBuyOptions(country = "US", subdivision = "NY") + runCatching { + controller.placeOrderAndStartPayment( + token = Token.usdf, + verifiedFiat = mockk(relaxed = true), + ) + } - assertTrue(result.isSuccess) - assertEquals("https://api.developer.coinbase.com/onramp/v1/buy/options", urlSlot.captured) - assertEquals("US", countrySlot.captured) - assertEquals("NY", subdivisionSlot.captured) + coVerify { buyOptionsCache.getCached(any()) } } @Test - fun `checkBuyOptions passes null params when omitted`() = runTest { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions( - url = any(), - jwt = any(), - country = isNull(), - subdivision = isNull(), - ) - } returns JsonObject(emptyMap()) - - val result = controller.checkBuyOptions() + fun `placeOrderAndStartPayment skips token resolution for launchpad tokens`() = runTest { + stubValidUser() + val launchpadToken = mockk(relaxed = true) { + every { symbol } returns "JEFFY" + every { address } returns Mint("54ggcQ23uen5b9QXMAns99MQNTKn7iyzq4wvCW6e8r25") + } - assertTrue(result.isSuccess) - coVerify { - api.getBuyOptions( - url = any(), - jwt = any(), - country = isNull(), - subdivision = isNull(), + runCatching { + controller.placeOrderAndStartPayment( + token = launchpadToken, + verifiedFiat = mockk(relaxed = true), ) } - } - - @Test - fun `checkBuyOptions fails when JWT fails`() = runTest { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("jwt error")) - - val result = controller.checkBuyOptions(country = "US") - assertTrue(result.isFailure) + coVerify(exactly = 0) { buyOptionsCache.getCached(any()) } + coVerify(exactly = 0) { buyOptionsCache.prefetch(any()) } } // endregion // region resolveOnRampToken - private fun buyOptionsResponseWithUsdf(): JsonObject = JsonObject( - mapOf( - "purchase_currencies" to JsonArray( - listOf( - JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), - JsonObject(mapOf("symbol" to JsonPrimitive("USDF"))), - ) - ) - ) - ) + private val mintsWithUsdf = setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF")) + private val mintsWithoutUsdf = setOf(BuyOptionsMint("USDC"), BuyOptionsMint("BTC")) - private fun buyOptionsResponseWithoutUsdf(): JsonObject = JsonObject( - mapOf( - "purchase_currencies" to JsonArray( - listOf( - JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), - JsonObject(mapOf("symbol" to JsonPrimitive("BTC"))), - ) - ) - ) - ) + private fun stubBuyOptionsCacheMints(mints: Set?) { + coEvery { buyOptionsCache.getCached(any()) } returns mints + coEvery { buyOptionsCache.prefetch(any()) } returns mints + } - private fun stubBuyOptionsApi(response: JsonObject) { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = any()) - } returns response - coEvery { - api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = isNull()) - } returns response + @Test + fun `resolveOnRampToken always returns USDF without fallback`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsCacheMints(mintsWithoutUsdf) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = false)) } @Test - fun `resolveOnRampToken returns USDF for non-NYC US phone when USDF tradable`() = runTest { - stubProfile(phone = "+14155551234") // San Francisco - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + fun `resolveOnRampToken skips cache lookup without fallback`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsCacheMints(mintsWithoutUsdf) + + controller.resolveOnRampToken(allowUsdcFallback = false) + + coVerify(exactly = 0) { buyOptionsCache.getCached(any()) } + coVerify(exactly = 0) { buyOptionsCache.prefetch(any()) } } @Test - fun `resolveOnRampToken returns USDF when phone is null`() = runTest { - stubProfile(phone = null) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + fun `resolveOnRampToken with fallback returns USDF when USDF tradable`() = runTest { + stubProfile(phone = "+14155551234") // San Francisco + stubBuyOptionsCacheMints(mintsWithUsdf) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken returns USDF for NYC phone when USDF is tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + fun `resolveOnRampToken with fallback returns USDF when phone is null`() = runTest { + stubProfile(phone = null) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken returns USDC for NYC phone when USDF not tradable`() = runTest { + fun `resolveOnRampToken with fallback returns USDC for NYC phone when USDF not tradable`() = runTest { stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) + stubBuyOptionsCacheMints(mintsWithoutUsdf) + assertEquals(Token.usdc, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken returns USDC for Canadian phone when USDF not tradable`() = runTest { + fun `resolveOnRampToken with fallback returns USDC for Canadian phone when USDF not tradable`() = runTest { stubProfile(phone = "+14165551234") // Toronto - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) + stubBuyOptionsCacheMints(mintsWithoutUsdf) + assertEquals(Token.usdc, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken returns USDF for international phone when USDF tradable`() = runTest { + fun `resolveOnRampToken with fallback returns USDF for international phone when USDF tradable`() = runTest { stubProfile(phone = "+442071234567") // UK - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + stubBuyOptionsCacheMints(mintsWithUsdf) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken defaults to USDF on API failure`() = runTest { + fun `resolveOnRampToken with fallback defaults to USDF when cache returns null`() = runTest { stubProfile(phone = "+12125551234") - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("fail")) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + coEvery { buyOptionsCache.getCached(any()) } returns null + coEvery { buyOptionsCache.prefetch(any()) } returns null + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken caches buy-options result per region`() = runTest { + fun `resolveOnRampToken with fallback uses cached result without calling prefetch`() = runTest { stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) + coEvery { buyOptionsCache.getCached(any()) } returns mintsWithoutUsdf - controller.resolveOnRampToken() - controller.resolveOnRampToken() + controller.resolveOnRampToken(allowUsdcFallback = true) - // API should only be called once due to caching - coVerify(exactly = 1) { api.getBuyOptions(any(), any(), any(), any()) } + coVerify(exactly = 0) { buyOptionsCache.prefetch(any()) } } // endregion diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index 2753d7315..af17c34cd 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:google-play-billing")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 2558bd2fd..21b21ee71 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -8,6 +8,7 @@ import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.onramp.BuyOptionsCache import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill @@ -134,6 +135,7 @@ class RealSessionController @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val analytics: FlipcashAnalyticsService, private val usdcSweep: UsdcDepositSweep, + private val buyOptionsCache: BuyOptionsCache, appSettingsCoordinator: AppSettingsCoordinator, ) : SessionController { @@ -249,6 +251,7 @@ class RealSessionController @Inject constructor( updateUserFlags() linkForPaymentIfNeeded() updateSettings() + prefetchBuyOptions() checkPendingItemsInFeed() bringActivityFeedCurrent() shareSheetController.checkForShare() @@ -358,6 +361,14 @@ class RealSessionController @Inject constructor( } } + private fun prefetchBuyOptions() { + if (userManager.authState.canAccessAuthenticatedApis) { + scope.launch { + buyOptionsCache.prefetchForCurrentUser() + } + } + } + private fun checkPendingItemsInFeed() { if (userManager.authState.canAccessAuthenticatedApis) { scope.launch { diff --git a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt index e1f913fdf..0554c953a 100644 --- a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt +++ b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt @@ -30,9 +30,9 @@ sealed interface OnRampPurchaseRequest { override val email: String, override val phoneNumber: String, override val destinationAddress: String, + override val purchaseCurrency: String, ): OnRampPurchaseRequest { override val paymentCurrency: String = "USD" - override val purchaseCurrency: String = "USDF" override val destinationNetwork: String = "solana" } @@ -54,9 +54,9 @@ sealed interface OnRampPurchaseRequest { override val email: String, override val phoneNumber: String, override val destinationAddress: String, + override val purchaseCurrency: String, ): OnRampPurchaseRequest { override val paymentCurrency: String = "USD" - override val purchaseCurrency: String = "USDF" override val destinationNetwork: String = "solana" } diff --git a/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt index 77db24020..5870108a9 100644 --- a/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt +++ b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt @@ -14,6 +14,7 @@ class OnRampPurchaseRequestTest { email = "test@example.com", phoneNumber = "+12125551234", destinationAddress = "some-sol-address", + purchaseCurrency = "USDF", ) private fun exclusiveOf() = OnRampPurchaseRequest.ExclusiveOfFees( @@ -23,6 +24,7 @@ class OnRampPurchaseRequestTest { email = "apple@example.com", phoneNumber = "+12125559876", destinationAddress = "another-sol-address", + purchaseCurrency = "USDF", ) // --- InclusiveOfFees --- @@ -43,7 +45,7 @@ class OnRampPurchaseRequestTest { } @Test - fun inclusiveHardcodesPurchaseCurrency() { + fun inclusivePassesThroughPurchaseCurrency() { assertEquals("USDF", inclusiveOf().asMap()["purchaseCurrency"]) }