Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PhoneVerificationViewModel.State, PhoneVerificationViewModel.Event>(
initialState = State(selectedLocale = phoneUtils.defaultCountryLocale),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -182,6 +184,9 @@ internal class PhoneVerificationViewModel @Inject constructor(
viewModelScope.launch {
profileController.updateUserProfile()
}
viewModelScope.launch {
buyOptionsCache.prefetchForCurrentUser()
}

viewModelScope.launch {
delay(1.seconds)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -39,6 +40,7 @@ class PhoneVerificationViewModelErrorTest {
private val userManager = mockk<UserManager>(relaxed = true)
private val featureFlags = mockk<FeatureFlagController>(relaxed = true)
private val resources = mockk<ResourceHelper>(relaxed = true)
private val buyOptionsCache = mockk<BuyOptionsCache>(relaxed = true)

private lateinit var dispatchers: TestDispatchers

Expand Down Expand Up @@ -70,6 +72,7 @@ class PhoneVerificationViewModelErrorTest {
featureFlags = featureFlags,
resources = resources,
dispatchers = dispatchers,
buyOptionsCache = buyOptionsCache,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Set<BuyOptionsMint>>()
private val mutex = Mutex()

fun getCached(region: PhoneRegion): Set<BuyOptionsMint>? = cache[region.cacheKey]

suspend fun prefetchForCurrentUser(): Set<BuyOptionsMint>? {
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<BuyOptionsMint>? {
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<BuyOptionsMint>? {
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<BuyOptionsMint> {
return response["purchase_currencies"]
?.jsonArray
?.mapNotNull { element ->
element.jsonObject["symbol"]?.jsonPrimitive?.content?.let { BuyOptionsMint(it) }
}
?.toSet()
?: emptySet()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.flipcash.app.onramp

@JvmInline
value class BuyOptionsMint(val symbol: String) {
companion object {
val USDF = BuyOptionsMint("USDF")
}
}
Original file line number Diff line number Diff line change
@@ -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 <T> execute(
scheme: String,
host: String,
path: String,
method: String,
call: suspend (Jwt) -> Result<T>,
): Result<T> {
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)
}
}
)
}
}
Loading
Loading