From aafb280275c782b31ed8b1a00d423be6b3cdfce3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 12 Jun 2026 12:34:14 -0400 Subject: [PATCH 1/2] feat(onramp): cache buyable token mints from Coinbase BuyOptions API Extract buy options caching into a @Singleton BuyOptionsCache with CoinbaseJwtExecutor for shared JWT execution. Prefetch on app foreground and after phone verification so resolveOnRampToken is instant. Support USDC fallback during deposit flow when USDF is not tradable in the user region. Signed-off-by: Brandon McAnsh --- .../contact-verification/build.gradle.kts | 1 + .../phone/PhoneVerificationViewModel.kt | 5 + .../PhoneVerificationViewModelErrorTest.kt | 3 + .../flipcash/app/onramp/BuyOptionsCache.kt | 80 +++++++ .../com/flipcash/app/onramp/BuyOptionsMint.kt | 8 + .../app/onramp/CoinbaseJwtExecutor.kt | 57 +++++ .../app/onramp/CoinbaseOnRampController.kt | 135 +++--------- .../app/onramp/BuyOptionsCacheTest.kt | 202 ++++++++++++++++++ .../onramp/CoinbaseOnRampControllerTest.kt | 162 ++++---------- apps/flipcash/shared/session/build.gradle.kts | 1 + .../session/internal/RealSessionController.kt | 11 + 11 files changed, 442 insertions(+), 223 deletions(-) create mode 100644 apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsCache.kt create mode 100644 apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/BuyOptionsMint.kt create mode 100644 apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseJwtExecutor.kt create mode 100644 apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/BuyOptionsCacheTest.kt 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..478fc9e04 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,56 +125,29 @@ 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( @@ -192,9 +158,10 @@ class CoinbaseOnRampController @Inject constructor( .mapCatching { (orderId, paymentLink) -> val order = OnrampOrder(orderId, paymentLink.url) - if (token.address == Mint.usdf) { + if (token.address == Mint.usdf || token.address == Mint.usdc) { // USDF goes to the deposit address — server auto-detects it. - // No stateful swap needed. + // USDC goes to the owner's ATA — UsdcDepositSweep converts it. + // Neither requires a stateful swap. startPayment(order, token, verifiedFiat, null) } else { val owner = userManager.accountCluster @@ -340,11 +307,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 +322,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..16fd4f760 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 @@ -18,13 +18,9 @@ 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 +36,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", @@ -77,7 +74,7 @@ class CoinbaseOnRampControllerTest { every { webViewChannelDetector.detect() } returns null controller = CoinbaseOnRampController( - jwtProvider = jwtProvider, + jwtExecutor = jwtExecutor, onRampApiEndpoint = onRampApiEndpoint, api = api, userManager = userManager, @@ -86,6 +83,7 @@ class CoinbaseOnRampControllerTest { transactionController = mockk(relaxed = true), googlePayReadiness = googlePayReadiness, webViewChannelDetector = webViewChannelDetector, + buyOptionsCache = buyOptionsCache, ) } @@ -263,160 +261,84 @@ class CoinbaseOnRampControllerTest { // endregion - // region checkBuyOptions - - @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()) + // region resolveOnRampToken - val result = controller.checkBuyOptions(country = "US", subdivision = "NY") + private val mintsWithUsdf = setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF")) + private val mintsWithoutUsdf = setOf(BuyOptionsMint("USDC"), BuyOptionsMint("BTC")) - assertTrue(result.isSuccess) - assertEquals("https://api.developer.coinbase.com/onramp/v1/buy/options", urlSlot.captured) - assertEquals("US", countrySlot.captured) - assertEquals("NY", subdivisionSlot.captured) + private fun stubBuyOptionsCacheMints(mints: Set?) { + coEvery { buyOptionsCache.getCached(any()) } returns mints + coEvery { buyOptionsCache.prefetch(any()) } returns mints } @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() - - assertTrue(result.isSuccess) - coVerify { - api.getBuyOptions( - url = any(), - jwt = any(), - country = isNull(), - subdivision = isNull(), - ) - } + fun `resolveOnRampToken always returns USDF without fallback`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsCacheMints(mintsWithoutUsdf) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = false)) } @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) - } - - // endregion - - // region resolveOnRampToken + fun `resolveOnRampToken skips cache lookup without fallback`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsCacheMints(mintsWithoutUsdf) - private fun buyOptionsResponseWithUsdf(): JsonObject = JsonObject( - mapOf( - "purchase_currencies" to JsonArray( - listOf( - JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), - JsonObject(mapOf("symbol" to JsonPrimitive("USDF"))), - ) - ) - ) - ) + controller.resolveOnRampToken(allowUsdcFallback = false) - 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 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 + coVerify(exactly = 0) { buyOptionsCache.getCached(any()) } + coVerify(exactly = 0) { buyOptionsCache.prefetch(any()) } } @Test - fun `resolveOnRampToken returns USDF for non-NYC US phone when USDF tradable`() = runTest { + fun `resolveOnRampToken with fallback returns USDF when USDF tradable`() = runTest { stubProfile(phone = "+14155551234") // San Francisco - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + stubBuyOptionsCacheMints(mintsWithUsdf) + assertEquals(Token.usdf, controller.resolveOnRampToken(allowUsdcFallback = true)) } @Test - fun `resolveOnRampToken returns USDF when phone is null`() = runTest { + fun `resolveOnRampToken with fallback returns USDF when phone is null`() = runTest { stubProfile(phone = null) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDF for NYC phone when USDF is tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + 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 { From 1a6e94e7134559a46e44e82b4fe2395e7b897954 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 12:01:19 -0400 Subject: [PATCH 2/2] feat(onramp): wire region-aware USDC/USDF resolution into Coinbase deposit flow resolveOnRampToken existed but wasn't called from the production deposit path. Now placeOrderAndStartPayment resolves the on-ramp token via BuyOptionsCache before placing the order, so users in regions where USDF isn't tradable (e.g. US/NY) buy USDC instead and UsdcDepositSweep auto-converts it. Also promotes purchaseCurrency from a hardcoded "USDF" body property to a constructor parameter on OnRampPurchaseRequest, threaded through as token.symbol.uppercase(). Signed-off-by: Brandon McAnsh --- .../app/onramp/CoinbaseOnRampController.kt | 20 ++++++--- .../onramp/CoinbaseOnRampControllerTest.kt | 42 +++++++++++++++++++ .../onramp/data/OnRampPurchaseRequest.kt | 4 +- .../onramp/data/OnRampPurchaseRequestTest.kt | 4 +- 4 files changed, 61 insertions(+), 9 deletions(-) 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 478fc9e04..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 @@ -154,15 +154,21 @@ class CoinbaseOnRampController @Inject constructor( 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 || token.address == Mint.usdc) { + if (resolvedToken.address == Mint.usdf || resolvedToken.address == Mint.usdc) { // USDF goes to the deposit address — server auto-detects it. // USDC goes to the owner's ATA — UsdcDepositSweep converts it. // Neither requires a stateful swap. - startPayment(order, token, verifiedFiat, null) + startPayment(order, resolvedToken, verifiedFiat, null) } else { val owner = userManager.accountCluster ?: throw IllegalStateException("No account cluster") @@ -175,7 +181,7 @@ class CoinbaseOnRampController @Inject constructor( fund = { Result.success(Unit) } ).getOrThrow() - startPayment(order, token, verifiedFiat, swapId) + startPayment(order, resolvedToken, verifiedFiat, swapId) } } } @@ -219,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) @@ -264,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) 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 16fd4f760..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,6 +13,7 @@ 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 @@ -62,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 @@ -261,6 +264,45 @@ class CoinbaseOnRampControllerTest { // endregion + // region placeOrderAndStartPayment token resolution + + @Test + fun `placeOrderAndStartPayment resolves on-ramp token for USDF deposits`() = runTest { + stubAccountCluster() + stubProfile(phone = "+14155551234") // valid US number for region resolution + stubBuyOptionsCacheMints(mintsWithUsdf) + + runCatching { + controller.placeOrderAndStartPayment( + token = Token.usdf, + verifiedFiat = mockk(relaxed = true), + ) + } + + coVerify { buyOptionsCache.getCached(any()) } + } + + @Test + fun `placeOrderAndStartPayment skips token resolution for launchpad tokens`() = runTest { + stubValidUser() + val launchpadToken = mockk(relaxed = true) { + every { symbol } returns "JEFFY" + every { address } returns Mint("54ggcQ23uen5b9QXMAns99MQNTKn7iyzq4wvCW6e8r25") + } + + runCatching { + controller.placeOrderAndStartPayment( + token = launchpadToken, + verifiedFiat = mockk(relaxed = true), + ) + } + + coVerify(exactly = 0) { buyOptionsCache.getCached(any()) } + coVerify(exactly = 0) { buyOptionsCache.prefetch(any()) } + } + + // endregion + // region resolveOnRampToken private val mintsWithUsdf = setOf(BuyOptionsMint("USDC"), BuyOptionsMint("USDF")) 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"]) }