From 4581c1c21f9cd44a999f46949ec604b313834608 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 10 Jun 2026 15:18:36 -0400 Subject: [PATCH 1/6] feat(deposit): deposit-first user experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prioritize depositing funds as the primary entry point for new and empty-wallet users. Balance, menu, send, messenger, and cash screens now surface deposit options contextually, routing through the unified Swap flow. Coinbase on-ramp gains phone-region resolution for better availability detection. Phantom deposits swap USDC→USDF directly via CoinbaseStableSwapper into the VM deposit PDA, eliminating the server-side sweep. Signed-off-by: Brandon McAnsh --- .../ui/navigation/AppScreenContent.kt | 6 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 12 -- .../core/src/main/res/values/strings.xml | 6 +- .../features/balance/build.gradle.kts | 1 + .../balance/internal/BalanceScreenContent.kt | 32 ++-- .../app/balance/internal/BalanceViewModel.kt | 14 +- .../internal/CurrencyCreatorViewModel.kt | 4 +- .../features/direct-send/build.gradle.kts | 1 + .../flipcash/app/directsend/SendFlowScreen.kt | 1 + .../directsend/internal/SendFlowViewModel.kt | 22 ++- .../internal/screens/ContactListScreen.kt | 9 +- apps/flipcash/features/menu/build.gradle.kts | 1 + .../app/menu/internal/MenuScreenContent.kt | 8 +- .../app/menu/internal/MenuScreenViewModel.kt | 10 + .../features/messenger/build.gradle.kts | 1 + .../flipcash/app/messenger/MessengerScreen.kt | 18 +- .../app/messenger/internal/ChatViewModel.kt | 22 ++- .../flipcash/app/scanner/internal/Scanner.kt | 6 +- .../flipcash/app/tokens/SwapEntryScreen.kt | 16 +- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 2 +- .../app/tokens/TokenTxProcessingScreen.kt | 55 ------ .../app/tokens/internal/TokenInfoScreen.kt | 2 +- .../app/tokens/internal/TokenSelectScreen.kt | 1 - .../shared/onramp/coinbase/build.gradle.kts | 1 + .../app/onramp/CoinbaseOnRampController.kt | 120 +++++++++--- .../app/onramp/CoinbaseOnRampHandler.kt | 12 +- .../app/onramp/CoinbaseOnRampState.kt | 9 +- .../com/flipcash/app/onramp/PhoneRegion.kt | 54 ++++++ .../onramp/CoinbaseOnRampControllerTest.kt | 176 +++++++++++++++++- .../flipcash/app/onramp/PhoneRegionTest.kt | 89 +++++++++ .../app/onramp/PhantomWalletController.kt | 119 +++++++++++- .../flipcash/app/payments/PurchaseMethod.kt | 1 + .../app/payments/PurchaseMethodController.kt | 2 + .../flipcash/app/payments/internal/Buttons.kt | 2 +- .../InternalPurchaseMethodController.kt | 37 ++++ apps/flipcash/shared/session/build.gradle.kts | 1 + .../flipcash/app/session/SessionController.kt | 2 + .../session/internal/RealSessionController.kt | 9 + .../SessionControllerGiftCardErrorTest.kt | 2 + .../flipcash/app/tokens/ui/SwapViewModel.kt | 64 ++++++- .../shared/userflags/build.gradle.kts | 2 +- definitions/flipcash/models/build.gradle.kts | 4 +- definitions/opencode/models/build.gradle.kts | 4 +- gradle.properties | 2 +- .../com/getcode/util/locale/LocaleUtils.kt | 2 +- .../coinbase/onramp/api/CoinbaseOnrampApi.kt | 10 + .../opencode/solana/TransactionBuilder.kt | 71 +++++++ .../solana/swap/UsdcDepositInstructions.kt | 52 ++++++ .../solana/swap/UsdfDepositInstructions.kt | 106 +++++++++++ 49 files changed, 1012 insertions(+), 191 deletions(-) create mode 100644 apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/PhoneRegion.kt create mode 100644 apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/PhoneRegionTest.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositInstructions.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 4f5c0e795..fb643991d 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -46,7 +46,7 @@ import com.flipcash.app.shareapp.ShareAppScreen import com.flipcash.app.tokens.SwapFlowScreen import com.flipcash.app.tokens.TokenInfoScreen import com.flipcash.app.tokens.TokenSelectScreen -import com.flipcash.app.tokens.TokenTxProcessingScreen + import com.flipcash.app.transactions.TransactionHistoryScreen import com.flipcash.app.userflags.UserFlagsScreen import com.flipcash.app.withdrawal.WithdrawalFlowScreen @@ -110,10 +110,6 @@ fun appEntryProvider( annotatedEntry { key -> SwapFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } - // TODO: fold this into above entry - annotatedEntry { key -> - TokenTxProcessingScreen(key.swapId, key.swapPurpose, key.amount, key.isFundingShortfall) - } annotatedEntry { TokenDiscoveryScreen() } annotatedEntry { key -> CurrencyCreatorFlowScreen(route = key, resultStateRegistry = resultStateRegistry) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index af1682b8c..781734011 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -15,15 +15,10 @@ import com.flipcash.app.core.verification.VerificationStep import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep import com.flipcash.app.core.onboarding.OnboardingStep -import com.getcode.navigation.NonDismissableRoute -import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult -import com.getcode.opencode.exchange.VerifiedFiat -import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint -import com.getcode.solana.keys.PublicKey import com.getcode.ui.core.RestrictionType import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -189,13 +184,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object PhantomConfirmTransaction: Token - @Serializable - data class TxProcessing( - val swapId: SwapId, - val swapPurpose: SwapPurpose? = null, - val amount: VerifiedFiat? = null, - val isFundingShortfall: Boolean = false, - ) : Token, NonDismissableRoute, NonDraggableRoute @Serializable data object Discovery: AppRoute diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 3a14dad7a..c9800e48a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -66,9 +66,11 @@ Your balance is held in US dollar stablecoins The current value of your currencies of US dollar stablecoins - Deposit + Deposit Deposit Deposit Funds + Deposit Funds + Withdraw Withdraw Funds @@ -235,7 +237,7 @@ Tap above to Add Cash to your wallet You don\'t have any cash yet.\nTap below to add cash to your wallet No Balance Yet - Buy a currency to get started, or get another Flipcash user to give you some cash + Deposit funds to get started Buy your first currency to get started Dismiss Success diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index e3e08d040..799c2f795 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt index a9b3fad8a..4223698c6 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -97,29 +97,23 @@ private fun BalanceScreenContent( Text( modifier = Modifier.fillMaxWidth(0.6f), - text = if (tokenState.discoveryEnabled) { - stringResource(R.string.description_noBalanceYetDiscover) - } else { - stringResource(R.string.description_noBalanceYet) - }, + text = stringResource(R.string.description_noBalanceYet), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, ) - if (tokenState.discoveryEnabled) { - CodeButton( - onClick = { - dispatchEvent( - BalanceViewModel.Event.OpenScreen(AppRoute.Token.Discovery) - ) - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - contentPadding = PaddingValues(), - text = stringResource(R.string.action_discoverCurrencies), - shape = CircleShape, - ) - } + CodeButton( + onClick = { + dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) + }, + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x2) + .align(Alignment.CenterHorizontally), + contentPadding = PaddingValues(), + text = stringResource(R.string.action_depositFunds), + shape = CircleShape, + ) } } }, diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 885072e81..044aa0c2a 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.AuthState @@ -23,6 +24,7 @@ internal class BalanceViewModel @Inject constructor( userManager: UserManager, userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, + purchaseMethodController: PurchaseMethodController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -38,6 +40,7 @@ internal class BalanceViewModel @Inject constructor( data object OpenCurrencySelection : Event data class OpenScreen(val screen: AppRoute) : Event + data object PresentDepositOptions: Event } init { @@ -46,9 +49,13 @@ internal class BalanceViewModel @Inject constructor( .flatMapLatest { userFlags.resolvedFlags } .mapNotNull { it.preferredOnRampProvider.effectiveValue } .filterIsInstance() - .onEach { provider -> - dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) - } + .onEach { provider -> dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { purchaseMethodController.presentDepositOptions() } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) } @@ -59,6 +66,7 @@ internal class BalanceViewModel @Inject constructor( is Event.OnPreferredOnRampProviderChanged -> { state -> state.copy(preferredOnRampProvider = event.provider) } + Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } } } diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 4ebbd93ae..067fb1c62 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.currencycreator.CurrencyCreatorCoordinator import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController import com.flipcash.app.onramp.DeeplinkError import com.flipcash.app.onramp.DeeplinkOnRampError +import com.flipcash.app.onramp.PhantomSwapResult import com.flipcash.app.onramp.PhantomWalletController import com.flipcash.app.onramp.isAlert import com.flipcash.app.onramp.isNetworkCause @@ -542,7 +543,8 @@ internal class CurrencyCreatorViewModel @Inject constructor( amount = totalAmount, fee = feeAmount, token = token, - ).onSuccess { swapId -> + ).onSuccess { result -> + val swapId = (result as PhantomSwapResult.WithSwapId).swapId dispatchEvent(Event.PurchaseSubmitted(swapId, token.address)) }.onFailure { error -> handlePhantomError(error) diff --git a/apps/flipcash/features/direct-send/build.gradle.kts b/apps/flipcash/features/direct-send/build.gradle.kts index ea3cfc9ec..a3629f34e 100644 --- a/apps/flipcash/features/direct-send/build.gradle.kts +++ b/apps/flipcash/features/direct-send/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt index 4205604e8..59715adf9 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt @@ -15,6 +15,7 @@ import com.flipcash.app.directsend.internal.screens.ContactListScreen import com.flipcash.app.directsend.internal.screens.ContactsPermissionGateScreen import com.flipcash.app.directsend.internal.screens.PhoneGateLandingScreen import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.flowSharedViewModel diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 3e2b7ecde..00b56688b 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -8,9 +8,11 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.ContactCoordinator.ContactState import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.contacts.device.PickedContactData +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.send.SendStep import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.permissions.PickedContact import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.directsend.R @@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -41,6 +44,7 @@ internal class SendFlowViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, private val tokenCoordinator: TokenCoordinator, private val resources: ResourceHelper, + purchaseMethodController: PurchaseMethodController, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -75,7 +79,8 @@ internal class SendFlowViewModel @Inject constructor( data class NavigateToChat(val contact: DeviceContact) : Event data class NavigateToDirectSend(val contact: DeviceContact) : Event - data object NavigateToDiscovery : Event + data object PresentDepositOptions : Event + data class NavigateToUsdfDepositOption(val route: AppRoute): Event } private val messengerEnabled = featureFlags.observe(FeatureFlag.Messenger) @@ -169,9 +174,9 @@ internal class SendFlowViewModel @Inject constructor( message = resources.getString(R.string.description_noBalanceYet), actions = listOf( BottomBarAction( - text = resources.getString(R.string.action_discoverCurrencies) + text = resources.getString(R.string.action_depositFunds) ) { - dispatchEvent(Event.NavigateToDiscovery) + dispatchEvent(Event.PresentDepositOptions) }, ), showCancel = true, @@ -185,6 +190,14 @@ internal class SendFlowViewModel @Inject constructor( } }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + purchaseMethodController.presentDepositOptions()?.let { route -> + dispatchEvent(Event.NavigateToUsdfDepositOption(route)) + } + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { event -> contactCoordinator.removeContact(event.e164) } @@ -272,7 +285,8 @@ internal class SendFlowViewModel @Inject constructor( is Event.SendInvite -> { state -> state } is Event.NavigateToChat -> { state -> state } is Event.NavigateToDirectSend -> { state -> state } - is Event.NavigateToDiscovery -> { state -> state } + is Event.PresentDepositOptions -> { state -> state } + is Event.NavigateToUsdfDepositOption -> { state -> state } } } } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index fe7d09952..bdfdac540 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -53,7 +53,6 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.send.SendResult import com.flipcash.app.core.send.SendStep import com.flipcash.app.directsend.internal.ContactListItem @@ -79,6 +78,7 @@ import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import com.getcode.view.LoadingSuccessState +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -129,9 +129,10 @@ internal fun ContactListScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() - .collect { - navigator.openAsSheet(AppRoute.Token.Discovery) + .filterIsInstance() + .map { it.route } + .collect { route -> + flowNavigator.navigate(route) } } diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 57614af98..ea1833270 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 66291ab87..938f9ccdb 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -1,6 +1,5 @@ package com.flipcash.app.menu.internal -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -15,7 +14,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -31,8 +29,6 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.noRippleClickable -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -101,10 +97,10 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { ) { TileButton( modifier = Modifier.weight(1f), - text = stringResource(R.string.action_depositFunds), + text = stringResource(R.string.action_deposit), icon = painterResource(R.drawable.ic_menu_deposit) ) { - navigator.push(AppRoute.Transfers.Deposit()) + viewModel.dispatchEvent(Event.PresentDepositOptions) } TileButton( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 33dabf0ca..23b877474 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.updates.ReleaseStage import com.flipcash.app.updates.ReleaseStageProvider import com.flipcash.app.userflags.UserFlagsCoordinator @@ -54,6 +55,7 @@ internal class MenuScreenViewModel @Inject constructor( featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, releaseStageProvider: ReleaseStageProvider, + purchaseMethodController: PurchaseMethodController, ) : BaseViewModel( initialState = State(), @@ -78,6 +80,7 @@ internal class MenuScreenViewModel @Inject constructor( data class OnAppVersionUpdated(val versionInfo: VersionInfo) : Event data class OnReleaseTrackDetermined(val stage: String): Event data class OnStaffUserDetermined(val staff: Boolean) : Event + data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object OnSwitchAccountsClicked : Event data class OnSwitchAccountTo(val entropy: String): Event @@ -146,6 +149,12 @@ internal class MenuScreenViewModel @Inject constructor( onError = { }, onSuccess = { dispatchEvent(Event.OnSwitchAccountTo(it)) } ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { purchaseMethodController.presentDepositOptions() } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) } internal companion object { @@ -217,6 +226,7 @@ internal class MenuScreenViewModel @Inject constructor( ) } + Event.PresentDepositOptions, Event.CheckForUpdate, Event.OnSwitchAccountsClicked, is Event.OpenScreen, diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 97c25b6cf..63709ee3a 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":libs:messaging")) implementation(project(":services:flipcash")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt index ebc85bda8..370e9be1b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt @@ -1,23 +1,12 @@ package com.flipcash.app.messenger -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.flowScopedViewModel -import com.getcode.theme.CodeTheme import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -44,9 +33,10 @@ fun MessengerScreen(e164: String, displayName: String) { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() - .collect { - navigator.openAsSheet(AppRoute.Token.Discovery) + .filterIsInstance() + .map { it.route } + .collect { route -> + navigator.push(route) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f9399e944..8af76d6b7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -9,10 +9,12 @@ import androidx.paging.flatMap import androidx.paging.insertSeparators import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.messenger.internal.screens.components.ChatListItem import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId @@ -67,6 +69,7 @@ internal class ChatViewModel @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val purchaseMethodController: PurchaseMethodController, private val userManager: UserManager, private val resources: ResourceHelper, ) : BaseViewModel( @@ -111,7 +114,8 @@ internal class ChatViewModel @Inject constructor( data object SendMessage : Event data class NavigateToAmountEntry(val contact: DeviceContact) : Event - data object NavigateToDiscovery : Event + data object PresentDepositOptions : Event + data class NavigateToUsdfDepositOption(val route: AppRoute): Event data object OnConfirmRequested : Event data class OnSendRequested( @@ -292,9 +296,9 @@ internal class ChatViewModel @Inject constructor( message = resources.getString(R.string.description_noBalanceYet), actions = listOf( BottomBarAction( - text = resources.getString(R.string.action_discoverCurrencies) + text = resources.getString(R.string.action_depositFunds) ) { - dispatchEvent(Event.NavigateToDiscovery) + dispatchEvent(Event.PresentDepositOptions) }, ), showCancel = true, @@ -304,6 +308,14 @@ internal class ChatViewModel @Inject constructor( dispatchEvent(Event.NavigateToAmountEntry(contact)) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + purchaseMethodController.presentDepositOptions()?.let { route -> + dispatchEvent(Event.NavigateToUsdfDepositOption(route)) + } + }.launchIn(viewModelScope) + // Send cash eventFlow.filterIsInstance() .onEach { (amount, token, destination) -> @@ -345,6 +357,7 @@ internal class ChatViewModel @Inject constructor( destinationOwner = destination, ).fold( onSuccess = { + tokenCoordinator.subtract(token, verifiedFiat.localFiat) Result.success(verifiedFiat) }, onFailure = { Result.failure(it) } @@ -444,7 +457,8 @@ internal class ChatViewModel @Inject constructor( } is Event.SendMessage -> { state -> state } is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } - is Event.NavigateToDiscovery -> { state -> state } + is Event.PresentDepositOptions -> { state -> state } + is Event.NavigateToUsdfDepositOption -> { state -> state } is Event.OnConfirmRequested -> { state -> state } is Event.OnSendRequested -> { state -> state } is Event.SendStateUpdated -> { state -> diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 29206cdca..a5e621521 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -92,9 +92,11 @@ internal fun Scanner() { message = context.getString(R.string.description_noBalanceYet), actions = listOf( BottomBarAction( - text = context.getString(R.string.action_discoverCurrencies) + text = context.getString(R.string.action_depositFunds) ) { - navigator.openAsSheet(AppRoute.Token.Discovery) + session.presentDepositOptions { route -> + navigator.openAsSheet(route) + } }, ), showCancel = true, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index aa8ebcf39..9dab9fff2 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -13,6 +13,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.onramp.CoinbaseOnRampCompletion import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel @@ -123,10 +124,17 @@ internal fun SwapEntryScreen( } LaunchedEffect(Unit) { - coinbaseOnRampController.pendingNavigation.collect { route -> - if (route is AppRoute.Token.TxProcessing) { - viewModel.dispatchEvent(SwapViewModel.Event.OnSwapIdChanged(route.swapId)) - flowNavigator.navigateTo(SwapStep.Processing) + coinbaseOnRampController.pendingCompletion.collect { completion -> + when (completion) { + is CoinbaseOnRampCompletion.SwapSubmitted -> { + viewModel.dispatchEvent(SwapViewModel.Event.OnSwapIdChanged(completion.swapId)) + flowNavigator.navigateTo(SwapStep.Processing) + } + is CoinbaseOnRampCompletion.DepositSubmitted -> { + viewModel.dispatchEvent(SwapViewModel.Event.UpdateProcessingState(loading = true)) + viewModel.dispatchEvent(SwapViewModel.Event.DepositSubmitted) + flowNavigator.navigateTo(SwapStep.Processing) + } } } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index a43385323..437fd3692 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -41,7 +41,7 @@ fun SwapFlowScreen( when (result) { SwapResult.Success -> { if (route.shortfall != null) outerNavigator.popAll() - else outerNavigator.popUntil { it is AppRoute.Token.Info } + else outerNavigator.pop() } SwapResult.OpenDeposit, SwapResult.Canceled -> outerNavigator.pop() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 2330df8c7..1070f4d25 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -3,19 +3,13 @@ package com.flipcash.app.tokens import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.tokens.internal.TokenTxProcessingScreen import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.app.tokens.ui.SwapViewModel.Event -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator -import com.getcode.opencode.exchange.VerifiedFiat -import com.getcode.opencode.internal.solana.model.SwapId import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -56,52 +50,3 @@ internal fun SwapProcessingScreen() { BackHandler { /* intercept */ } } - -/** - * Standalone processing screen for OnRamp and external wallet paths that - * live outside the swap flow. - */ -@Composable -fun TokenTxProcessingScreen( - swapId: SwapId, - swapPurpose: SwapPurpose?, - swapAmount: VerifiedFiat?, - isFundingShortfall: Boolean = false, -) { - val navigator = LocalCodeNavigator.current - val viewModel = hiltViewModel() - - TokenTxProcessingScreen(viewModel = viewModel) - - LaunchedEffect(viewModel, swapId) { - viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) - if (swapPurpose != null) { - viewModel.dispatchEvent(Event.OnPurposeChanged(swapPurpose)) - } - if (swapAmount != null) { - viewModel.dispatchEvent(Event.OnAmountAccepted(swapAmount, swapAmount.localFiat.nativeAmount)) - } - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - if (isFundingShortfall) { - navigator.popAll() - } else { - navigator.popUntil { it is AppRoute.Token.Info } - } - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.popUntil { it is AppRoute.Token.Info } - }.launchIn(this) - } - - BackHandler { /* intercept */ } -} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index 426d6850f..bee7536f2 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -325,7 +325,7 @@ private fun RowScope.ReserveButtonOptions( CodeButton( modifier = Modifier.weight(1f), buttonState = ButtonState.Filled, - text = stringResource(R.string.action_depositFunds), + text = stringResource(R.string.action_deposit), ) { dispatch( TokenInfoViewModel.Event.OpenScreen( diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt index ca0913470..851b8f91c 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt @@ -52,7 +52,6 @@ private fun SelectTokenScreenContent( ), showSelections = state.purpose is TokenPurpose.Select, showFlags = state.purpose !is TokenPurpose.Select, - includeReserves = state.purpose !is TokenPurpose.Select, emptyState = { Box( modifier = Modifier diff --git a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts index 008444b3e..204116fba 100644 --- a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts +++ b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(libs.androidx.localbroadcastmanager) implementation(libs.bundles.kotlinx.serialization) + implementation(libs.lib.phone.number.google) implementation(libs.play.services.wallet) implementation(libs.kotlinx.coroutines.play.services) implementation(project(":libs:messaging")) 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 5bad26bcf..ef5be1611 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 @@ -19,13 +19,15 @@ import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.CurrencyCode 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.opencode.model.transactions.SwapFundingSource +import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 -import com.flipcash.app.core.AppRoute import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError import com.getcode.utils.CodeServerError import com.getcode.utils.ErrorUtils @@ -43,6 +45,10 @@ 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 @@ -75,14 +81,14 @@ class CoinbaseOnRampController @Inject constructor( private val _state = MutableStateFlow(CoinbaseOnRampState.Idle) val state: StateFlow = _state.asStateFlow() - private val _pendingNavigation = MutableSharedFlow(extraBufferCapacity = 1) - val pendingNavigation: SharedFlow = _pendingNavigation.asSharedFlow() + private val _pendingCompletion = MutableSharedFlow(extraBufferCapacity = 1) + val pendingCompletion: SharedFlow = _pendingCompletion.asSharedFlow() - fun emitPendingNavigation(route: AppRoute) { - _pendingNavigation.tryEmit(route) + fun emitCompletion(completion: CoinbaseOnRampCompletion) { + _pendingCompletion.tryEmit(completion) } - fun startPayment(order: OnrampOrder, token: Token, amount: VerifiedFiat, swapId: SwapId) { + private fun startPayment(order: OnrampOrder, token: Token, amount: VerifiedFiat, swapId: SwapId?) { _state.value = CoinbaseOnRampState.Paying(order, token, amount, swapId) } @@ -126,30 +132,90 @@ 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. + */ + suspend fun resolveOnRampToken(): Token { + 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 + } + + 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, + ) + } + } + ) + } + suspend fun placeOrderAndStartPayment( token: Token, verifiedFiat: VerifiedFiat, ): Result { - return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount) + return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount, token) .mapCatching { (orderId, paymentLink) -> - val owner = userManager.accountCluster - ?: throw IllegalStateException("No account cluster") - - val swapId = transactionController.buy( - owner = owner, - amount = verifiedFiat, - of = token, - source = SwapFundingSource.CoinbaseOnramp(orderId = orderId), - fund = { Result.success(Unit) } - ).getOrThrow() - val order = OnrampOrder(orderId, paymentLink.url) - startPayment(order, token, verifiedFiat, swapId) + + if (token.address == Mint.usdf) { + // USDF goes to the deposit address — server auto-detects it. + // No stateful swap needed. + startPayment(order, token, verifiedFiat, null) + } else { + val owner = userManager.accountCluster + ?: throw IllegalStateException("No account cluster") + + val swapId = transactionController.buy( + owner = owner, + amount = verifiedFiat, + of = token, + source = SwapFundingSource.CoinbaseOnramp(orderId = orderId), + fund = { Result.success(Unit) } + ).getOrThrow() + + startPayment(order, token, verifiedFiat, swapId) + } } } suspend fun placeOrderInclusiveOfFees( amount: Fiat, + token: Token = Token.usdf, ): Result { val usdAmount = if (amount.currencyCode == CurrencyCode.USD) { amount.decimalValue.toInt().toString() @@ -162,9 +228,8 @@ class CoinbaseOnRampController @Inject constructor( val owner = userManager.accountCluster ?: return Result.failure(Throwable("Owner not found")) val userRef = owner.authorityPublicKey.base58() - val usdfSwapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) - val destination = usdfSwapAccounts.pda.publicKey.base58() + val destination = destinationForToken(owner, token) val email = userManager.profile?.verifiedEmailAddress val phone = userManager.profile?.verifiedPhoneNumber @@ -195,6 +260,7 @@ class CoinbaseOnRampController @Inject constructor( suspend fun placeOrderExclusiveOfFees( amount: Fiat, + token: Token = Token.usdf, ): Result { val usdAmount = if (amount.currencyCode == CurrencyCode.USD) { amount.decimalValue.toInt().toString() @@ -207,9 +273,8 @@ class CoinbaseOnRampController @Inject constructor( val owner = userManager.accountCluster ?: return Result.failure(Throwable("Owner not found")) val userRef = owner.authorityPublicKey.base58() - val usdfSwapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) - val destination = usdfSwapAccounts.pda.publicKey.base58() + val destination = destinationForToken(owner, token) val email = userManager.profile?.verifiedEmailAddress val phone = userManager.profile?.verifiedPhoneNumber @@ -274,6 +339,15 @@ 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() + } + } + private suspend fun requestJwtAndExecute( scheme: String, host: String, diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt index cfd1907b5..1115b7133 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalResources -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError import com.flipcash.shared.onramp.coinbase.R import com.getcode.manager.BottomBarManager @@ -39,9 +37,13 @@ fun CoinbaseOnRampHandler( is CoinbaseOnRampState.Completed -> { LaunchedEffect(current) { - controller.emitPendingNavigation( - AppRoute.Token.TxProcessing(current.swapId, SwapPurpose.Buy(current.token.address), current.amount) - ) + val swapId = current.swapId + val completion = if (swapId != null) { + CoinbaseOnRampCompletion.SwapSubmitted(swapId) + } else { + CoinbaseOnRampCompletion.DepositSubmitted + } + controller.emitCompletion(completion) controller.reset() } } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt index 1335aa5bd..2c725ea7b 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt @@ -16,7 +16,12 @@ data class OnrampOrder( sealed interface CoinbaseOnRampState { data object Idle : CoinbaseOnRampState - data class Paying(val order: OnrampOrder, val token: Token, val amount: VerifiedFiat, val swapId: SwapId) : CoinbaseOnRampState - data class Completed(val swapId: SwapId, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState + data class Paying(val order: OnrampOrder, val token: Token, val amount: VerifiedFiat, val swapId: SwapId? = null) : CoinbaseOnRampState + data class Completed(val swapId: SwapId?, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState data class Failed(val error: CoinbaseOnRampWebError) : CoinbaseOnRampState } + +sealed interface CoinbaseOnRampCompletion { + data class SwapSubmitted(val swapId: SwapId) : CoinbaseOnRampCompletion + data object DepositSubmitted : CoinbaseOnRampCompletion +} diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/PhoneRegion.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/PhoneRegion.kt new file mode 100644 index 000000000..1255d668e --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/PhoneRegion.kt @@ -0,0 +1,54 @@ +package com.flipcash.app.onramp + +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.NumberParseException + +/** + * Region derived from a phone number for Coinbase buy-options lookups. + * + * @property country ISO 3166-1 alpha-2 country code (e.g., "US", "CA", "GB") + * @property subdivision ISO 3166-2 subdivision code, if detectable (e.g., "NY") + */ +data class PhoneRegion( + val country: String, + val subdivision: String? = null, +) { + val cacheKey: String get() = "$country${subdivision?.let { "-$it" } ?: ""}" +} + +/** + * NYC area codes used to infer a New York subdivision. + * Only needed for US numbers where Coinbase has state-level restrictions. + */ +private val NYC_AREA_CODES = setOf("212", "718", "917", "646", "347", "929", "586") + +private val phoneUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance() + +/** + * Extracts the [PhoneRegion] from an E.164 phone number using libphonenumber + * for country detection and area code heuristics for US subdivision. + * + * @return the detected region, or null if the number can't be parsed. + */ +fun regionFromPhone(phone: String): PhoneRegion? { + val parsed = try { + phoneUtil.parse(phone, null) + } catch (_: NumberParseException) { + return null + } + + val country = phoneUtil.getRegionCodeForNumber(parsed) ?: return null + val subdivision = if (country == "US") { + subdivisionFromUsNumber(parsed.nationalNumber.toString()) + } else { + null + } + + return PhoneRegion(country = country, subdivision = subdivision) +} + +private fun subdivisionFromUsNumber(nationalNumber: String): String? { + if (nationalNumber.length < 3) return null + val areaCode = nationalNumber.take(3) + return if (areaCode in NYC_AREA_CODES) "NY" else null +} 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 fd6ec6015..747e0acf7 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 @@ -11,15 +11,20 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.CurrencyCode 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 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 @@ -58,9 +63,16 @@ class CoinbaseOnRampControllerTest { // mock the extension property and timelockSwapAccounts to avoid the native call mockkStatic("com.getcode.opencode.model.financial.MintMetadataKt") mockkStatic("com.getcode.opencode.internal.solana.extensions.TokenKt") - val fakeToken = mockk(relaxed = true) - every { Token.usdf } returns fakeToken - every { fakeToken.timelockSwapAccounts(any()) } returns mockk(relaxed = true) + val fakeUsdf = mockk(relaxed = true) { + every { symbol } returns "USDF" + } + val fakeUsdc = mockk(relaxed = true) { + every { symbol } returns "USDC" + } + every { Token.usdf } returns fakeUsdf + every { Token.usdc } returns fakeUsdc + every { fakeUsdf.timelockSwapAccounts(any()) } returns mockk(relaxed = true) + every { fakeUsdc.timelockSwapAccounts(any()) } returns mockk(relaxed = true) every { webViewChannelDetector.detect() } returns null @@ -250,6 +262,164 @@ 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()) + + val result = controller.checkBuyOptions(country = "US", subdivision = "NY") + + assertTrue(result.isSuccess) + assertEquals("https://api.developer.coinbase.com/onramp/v1/buy/options", urlSlot.captured) + assertEquals("US", countrySlot.captured) + assertEquals("NY", subdivisionSlot.captured) + } + + @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(), + ) + } + } + + @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 + + private fun buyOptionsResponseWithUsdf(): JsonObject = JsonObject( + mapOf( + "purchase_currencies" to JsonArray( + listOf( + JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), + JsonObject(mapOf("symbol" to JsonPrimitive("USDF"))), + ) + ) + ) + ) + + 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 + } + + @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()) + } + + @Test + fun `resolveOnRampToken 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()) + } + + @Test + fun `resolveOnRampToken returns USDC for NYC phone when USDF not tradable`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) + assertEquals(Token.usdc, controller.resolveOnRampToken()) + } + + @Test + fun `resolveOnRampToken returns USDC for Canadian phone when USDF not tradable`() = runTest { + stubProfile(phone = "+14165551234") // Toronto + stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) + assertEquals(Token.usdc, controller.resolveOnRampToken()) + } + + @Test + fun `resolveOnRampToken returns USDF for international phone when USDF tradable`() = runTest { + stubProfile(phone = "+442071234567") // UK + stubBuyOptionsApi(buyOptionsResponseWithUsdf()) + assertEquals(Token.usdf, controller.resolveOnRampToken()) + } + + @Test + fun `resolveOnRampToken defaults to USDF on API failure`() = runTest { + stubProfile(phone = "+12125551234") + coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("fail")) + assertEquals(Token.usdf, controller.resolveOnRampToken()) + } + + @Test + fun `resolveOnRampToken caches buy-options result per region`() = runTest { + stubProfile(phone = "+12125551234") + stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) + + controller.resolveOnRampToken() + controller.resolveOnRampToken() + + // API should only be called once due to caching + coVerify(exactly = 1) { api.getBuyOptions(any(), any(), any(), any()) } + } + + // endregion } class CoinbaseOnRampApiErrorParseTest { diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/PhoneRegionTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/PhoneRegionTest.kt new file mode 100644 index 000000000..f0edd52a9 --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/PhoneRegionTest.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.onramp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PhoneRegionTest { + + // region NYC area codes + + @Test + fun `212 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+12125551234")) + } + + @Test + fun `718 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+17185551234")) + } + + @Test + fun `917 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+19175551234")) + } + + @Test + fun `646 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+16465551234")) + } + + @Test + fun `347 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+13475551234")) + } + + @Test + fun `929 area code returns US-NY`() { + assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+19295551234")) + } + + // endregion + + // region US non-NYC + + @Test + fun `non-NYC US area code returns US with no subdivision`() { + assertEquals(PhoneRegion("US", null), regionFromPhone("+14155551234")) // SF + } + + // endregion + + // region international + + @Test + fun `Canadian number returns CA`() { + assertEquals(PhoneRegion("CA", null), regionFromPhone("+14165551234")) // Toronto + } + + @Test + fun `UK number returns GB`() { + assertEquals(PhoneRegion("GB", null), regionFromPhone("+442071234567")) + } + + @Test + fun `German number returns DE`() { + assertEquals(PhoneRegion("DE", null), regionFromPhone("+4915112345678")) + } + + // endregion + + // region edge cases + + @Test + fun `invalid number returns null`() { + assertNull(regionFromPhone("12345")) + } + + @Test + fun `empty string returns null`() { + assertNull(regionFromPhone("")) + } + + @Test + fun `non-numeric string returns null`() { + assertNull(regionFromPhone("not-a-phone")) + } + + // endregion +} diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt index 11b8432e0..9e55c326b 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/PhantomWalletController.kt @@ -9,6 +9,7 @@ import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdc import com.getcode.opencode.model.transactions.FundSwapPool import com.getcode.opencode.model.transactions.LiquidityPool import com.getcode.opencode.model.transactions.SwapFundingSource @@ -43,6 +44,11 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import javax.inject.Inject +sealed interface PhantomSwapResult { + data class WithSwapId(val swapId: SwapId) : PhantomSwapResult + data class DepositCompleted(val amount: Long) : PhantomSwapResult +} + class PhantomWalletController @Inject constructor( private val userManager: UserManager, private val userFlags: UserFlagsCoordinator, @@ -70,7 +76,7 @@ class PhantomWalletController @Inject constructor( token: Token, onConnectLaunching: () -> Unit = {}, onSignLaunching: (SwapId) -> Unit = {}, - ): Result { + ): Result { phantomConnector.beginCeremony() return try { onConnectLaunching() @@ -117,12 +123,19 @@ class PhantomWalletController @Inject constructor( fee: LocalFiat, token: Token, onBeforeSign: (suspend (SwapId) -> Unit)? = null, - ): Result { + ): Result { val sender = getSolanaAddress() + val quarks = amount.localFiat.underlyingTokenAmount.quarks - checkBalances(sender, amount.localFiat.underlyingTokenAmount.quarks) + checkBalances(sender, quarks) .getOrElse { return Result.failure(it) } + val isUsdfDeposit = token.address == Mint.usdf + if (isUsdfDeposit) { + return executeUsdfDeposit(sender, amount) + } + + // Launchpad token buy — existing flow val (tx, swapId) = buildSwapTransaction(sender, amount) .getOrElse { return Result.failure(it) } @@ -144,7 +157,29 @@ class PhantomWalletController @Inject constructor( sendSwapTransaction(signedTxBase58, signature, amount, fee, token, swapId) .getOrElse { return Result.failure(it) } - return Result.success(swapId) + return Result.success(PhantomSwapResult.WithSwapId(swapId)) + } + + private suspend fun executeUsdfDeposit( + sender: PublicKey, + amount: VerifiedFiat, + ): Result { + val owner = requireNotNull(userManager.accountCluster) { "Owner is null" } + val quarks = amount.localFiat.underlyingTokenAmount.quarks + + val tx = buildDepositTransaction(sender, owner.authorityPublicKey, quarks) + .getOrElse { return Result.failure(it) } + + val signedTxBase58 = try { + phantomSdk.solana.signTransaction(tx.encode().base64) + } catch (e: Exception) { + return Result.failure(mapConnectorError(e)) + } + + sendUsdfDepositTransaction(signedTxBase58) + .getOrElse { return Result.failure(it) } + + return Result.success(PhantomSwapResult.DepositCompleted(quarks)) } internal suspend fun checkBalances( @@ -237,6 +272,82 @@ class PhantomWalletController @Inject constructor( } } + private suspend fun buildDepositTransaction( + sender: PublicKey, + owner: PublicKey, + amount: Long, + ): Result { + return withContext(Dispatchers.IO) { + try { + val recentBlockhash = connection.getLatestBlockhash() + + val poolAddress = FundSwapPool.CoinbaseStableSwapper.poolAddress + val poolData = driver.getAccountData(poolAddress).getOrThrow() + val pool = FundSwapPool.CoinbaseStableSwapper.fromAccountData(poolData) + + val transaction = TransactionBuilder.usdfDeposit( + owner = owner, + sender = sender, + amount = amount, + feeRecipient = pool.feeRecipient, + blockhash = Hash(recentBlockhash), + ) + + driver.simulateTransaction(transaction.encode().base64) + .getOrElse { cause -> + return@withContext Result.failure( + DeeplinkOnRampError.FailedToSimulateTransaction( + message = cause.message, cause = cause + ) + ) + } + + Result.success(transaction) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace("USDF deposit tx build failed", type = TraceType.Error, error = e) + Result.failure( + DeeplinkOnRampError.FailedToCreateTransaction(message = e.message, cause = e) + ) + } + } + } + + private suspend fun sendUsdfDepositTransaction( + signedTxBase58: String, + ): Result { + return withContext(Dispatchers.IO) { + // Submit USDC→USDF swap to Solana — USDF lands directly in the + // deposit PDA ATA, so no server-side USDC sweep is needed. + driver.sendTransaction(signedTxBase58) + .map { } + .onFailure { error -> + val code = (error as? RpcException)?.code?.toLong() + when { + error is RpcException && error.isBlockhashNotFound -> { + ErrorUtils.handleError(error) + return@withContext Result.failure( + DeeplinkOnRampError.TransactionExpired( + message = error.message, cause = error, + ) + ) + } + else -> { + ErrorUtils.handleError(error) + return@withContext Result.failure( + DeeplinkOnRampError.FailedToSendTransaction( + code = code ?: -99L, message = error.message, cause = error, + ) + ) + } + } + } + + Result.success(Unit) + } + } + private suspend fun sendSwapTransaction( signedTxBase58: String, signature: Signature, diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt index 6280a1c5a..9a9b89d26 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt @@ -17,6 +17,7 @@ data class PurchaseMethodMetadata( val mint: Mint? = null, val purchaseAmount: Fiat? = null, val feeAmount: Fiat? = null, + val showReserves: Boolean = true, val paymentAction: PaymentAction = PaymentAction.Buy, val canUseOtherWallets: Boolean = false, ) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt index 1c64a6b72..2803e7609 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt @@ -1,5 +1,6 @@ package com.flipcash.app.payments +import com.flipcash.app.core.AppRoute import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -8,4 +9,5 @@ interface PurchaseMethodController { val selections: Flow fun present(metadata: PurchaseMethodMetadata = PurchaseMethodMetadata()) fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) + suspend fun presentDepositOptions(): AppRoute? } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt index 6799ef021..a1ecfac6e 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt @@ -54,7 +54,7 @@ internal fun purchaseOptions( ) ) } - if (state.hasReserves) { + if (state.hasReserves && metadata.showReserves) { val minimumAmountNeeded = metadata.purchaseAmount ?: Fiat.MIN_VALUE if (state.reservesBalance.nativeAmount >= minimumAmountNeeded) { add( diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 0730e8030..12cdb52ca 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -2,6 +2,9 @@ package com.flipcash.app.payments.internal import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.FundingSource +import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata @@ -15,6 +18,7 @@ import com.flipcash.shared.payments.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -24,12 +28,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -53,6 +59,8 @@ class InternalPurchaseMethodController @Inject constructor( private val _selections = MutableSharedFlow() override val selections: Flow = _selections.asSharedFlow() + private val _dismissals = MutableSharedFlow() + init { combine( features.observe(FeatureFlag.CoinbaseOnRamp), @@ -93,9 +101,11 @@ class InternalPurchaseMethodController @Inject constructor( override fun present(metadata: PurchaseMethodMetadata) { _state.update { it.copy(canUseOtherWallets = metadata.canUseOtherWallets) } + var selected = false BottomBarManager.showMessage( title = resources.getString(R.string.prompt_title_selectPurchaseMethod), actions = purchaseOptions(_state.value, metadata, resources) { method -> + selected = true scope.launch { val selection = PurchaseMethodSelection(method, metadata) delay(300) @@ -104,6 +114,33 @@ class InternalPurchaseMethodController @Inject constructor( }, showCancel = false, showScrim = true, + onDismiss = { + if (!selected) { + scope.launch { _dismissals.emit(Unit) } + } + }, ) } + + override suspend fun presentDepositOptions(): AppRoute? { + delay(150) + present(PurchaseMethodMetadata(mint = Mint.usdf, showReserves = false, canUseOtherWallets = true)) + + val result = merge( + selections.map { it.method }, + _dismissals.map { null }, + ).first() + + return when (result) { + PurchaseMethod.CoinbaseOnRamp -> AppRoute.Token.Swap( + purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase) + ) + PurchaseMethod.PhantomWallet -> AppRoute.Token.Swap( + purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Phantom) + ) + PurchaseMethod.OtherWallet -> AppRoute.Transfers.Deposit(showOtherOptions = true) + is PurchaseMethod.CashReserves -> null + null -> null + } + } } \ No newline at end of file diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index a9fe10eb1..2753d7315 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:payments")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:workers")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 4be8041c7..b057f1ae6 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -6,6 +6,7 @@ import com.flipcash.app.core.bill.BillState import com.flipcash.app.session.BillDeterminationResult.ActedUpon import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint +import com.flipcash.app.core.AppRoute import com.getcode.ui.core.RestrictionType import com.getcode.util.permissions.PermissionResult import com.kik.kikx.models.ScannableKikCode @@ -29,6 +30,7 @@ interface SessionController { fun dismissBill(action: BillDeterminationResult) fun onCodeScan(code: ScannableKikCode) fun openCashLink(cashLink: String?) + fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null) } data class SessionState( 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 b62b7f891..acd98b8a4 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 @@ -9,6 +9,7 @@ import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.PaymentValuation @@ -18,6 +19,7 @@ import com.flipcash.app.core.internal.updater.ProfileUpdater import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.session.BillDeterminationResult import com.flipcash.app.session.Grabbed import com.flipcash.app.session.PutInWallet @@ -128,6 +130,7 @@ class RealSessionController @Inject constructor( private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, private val featureFlagController: FeatureFlagController, + private val purchaseMethodController: PurchaseMethodController, private val analytics: FlipcashAnalyticsService, private val usdcSweep: UsdcDepositSweep, appSettingsCoordinator: AppSettingsCoordinator, @@ -775,6 +778,12 @@ class RealSessionController @Inject constructor( } } + override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { + scope.launch { + purchaseMethodController.presentDepositOptions()?.let { onRoute?.invoke(it) } + } + } + private fun claimGiftCard( owner: AccountCluster, entropy: String, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 6936c57c6..6d9d198fd 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -30,6 +30,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.flipcash.app.billing.BillingClient import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.payments.PurchaseMethodController import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.util.vibration.Vibrator import com.getcode.opencode.model.accounts.GiftCardAccount @@ -114,6 +115,7 @@ class SessionControllerGiftCardErrorTest { usdcSweep = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + purchaseMethodController = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 52ce92cb9..f1bac6343 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.onramp.CoinbaseOnRampState import com.flipcash.app.onramp.DeeplinkError import com.flipcash.app.onramp.DeeplinkOnRampError import com.flipcash.app.onramp.OnRampAuthError +import com.flipcash.app.onramp.PhantomSwapResult import com.flipcash.app.onramp.PhantomWalletController import com.flipcash.app.onramp.PurchaseGate import com.flipcash.app.onramp.isAlert @@ -64,6 +65,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -228,8 +230,9 @@ class SwapViewModel @Inject constructor( data object StartPhantomCeremony : Event data object PhantomConnected : Event - data class PhantomNavigateToProcessing(val swapId: SwapId) : Event + data class PhantomNavigateToProcessing(val swapId: SwapId? = null) : Event data object PhantomCeremonyFailed : Event + data object DepositSubmitted : Event data class CreateAndSendTransactionToWallet(val token: Token, val amount: VerifiedFiat) : Event @@ -519,12 +522,21 @@ class SwapViewModel @Inject constructor( purchaseAmount = Fiat(delegateState.enteredAmount, rate.currency), canUseOtherWallets = true, // allow external USDC deposit as a "purchase" option ) - val methods = purchaseMethodController.state.value.availableMethods - if (methods.size == 1) { - // Single method — skip sheet, handle directly - purchaseMethodController.select(methods.first(), metadata) + val pinnedMethod = when (purpose.fundingSource) { + FundingSource.Coinbase -> PurchaseMethod.CoinbaseOnRamp + FundingSource.Phantom -> PurchaseMethod.PhantomWallet + FundingSource.Flexible -> null + } + if (pinnedMethod != null) { + purchaseMethodController.select(pinnedMethod, metadata) } else { - purchaseMethodController.present(metadata) + val methods = purchaseMethodController.state.value.availableMethods + if (methods.size == 1) { + // Single method — skip sheet, handle directly + purchaseMethodController.select(methods.first(), metadata) + } else { + purchaseMethodController.present(metadata) + } } } } @@ -602,6 +614,20 @@ class SwapViewModel @Inject constructor( .onEach { connectPhantomWallet() } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .map { tokenCoordinator.balanceForToken(Mint.usdf).first() } + .flatMapLatest { baseline -> + // Observe balance — UsdcDepositSweep handles the actual + // USDC→USDF sweep + polling, we just wait for the result. + tokenCoordinator.balanceForToken(Mint.usdf) + .filter { it > baseline } + } + .onEach { + feedCoordinator.fetchSinceLatest() + dispatchEvent(Event.UpdateProcessingState(loading = false, success = true)) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { stateFlow.value.amountEntryState.selectedAmount } @@ -792,6 +818,14 @@ class SwapViewModel @Inject constructor( return@onEach } + dispatchEvent( + Event.OnAmountAccepted( + amountFiat, + netTransferAmount = amountFiat.localFiat.nativeAmount, + enteredAmount = enteredAmount, + feeAmount = feeAmount, + ) + ) dispatchEvent(Event.UpdateBuyState(loading = true)) val token = stateFlow.value.tokenWithBalance?.token ?: return@onEach @@ -961,10 +995,19 @@ class SwapViewModel @Inject constructor( amount.localFiat.underlyingTokenAmount ) }, - ).onSuccess { swapId -> - dispatchEvent(Event.UpdateProcessingState(loading = true)) - dispatchEvent(Event.PhantomNavigateToProcessing(swapId)) - dispatchEvent(Event.OnSwapIdChanged(swapId)) + ).onSuccess { result -> + when (result) { + is PhantomSwapResult.WithSwapId -> { + dispatchEvent(Event.UpdateProcessingState(loading = true)) + dispatchEvent(Event.PhantomNavigateToProcessing(result.swapId)) + dispatchEvent(Event.OnSwapIdChanged(result.swapId)) + } + is PhantomSwapResult.DepositCompleted -> { + dispatchEvent(Event.UpdateProcessingState(loading = true)) + dispatchEvent(Event.PhantomNavigateToProcessing()) + dispatchEvent(Event.DepositSubmitted) + } + } }.onFailure { error -> handlePhantomError(error) } @@ -1090,6 +1133,7 @@ class SwapViewModel @Inject constructor( Event.PhantomConnected, is Event.PhantomNavigateToProcessing, Event.PhantomCeremonyFailed, + Event.DepositSubmitted, Event.OnAmountConfirmed -> { state -> state } is Event.UpdateBuyState -> { state -> diff --git a/apps/flipcash/shared/userflags/build.gradle.kts b/apps/flipcash/shared/userflags/build.gradle.kts index 661ff50c4..6134b026e 100644 --- a/apps/flipcash/shared/userflags/build.gradle.kts +++ b/apps/flipcash/shared/userflags/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.flipcash.android.library.compose) } android { diff --git a/definitions/flipcash/models/build.gradle.kts b/definitions/flipcash/models/build.gradle.kts index 08bee9525..8bb42623a 100644 --- a/definitions/flipcash/models/build.gradle.kts +++ b/definitions/flipcash/models/build.gradle.kts @@ -7,7 +7,9 @@ plugins { alias(libs.plugins.protobuf.validate) } -val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) ":osx-x86_64" else "" +val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) { + if (System.getProperty("os.arch") == "aarch64") ":osx-aarch_64" else ":osx-x86_64" +} else "" version = "0.0.1" group = "com.codeinc.flipcash.gen" diff --git a/definitions/opencode/models/build.gradle.kts b/definitions/opencode/models/build.gradle.kts index 10f9aea90..1b58f7cf2 100644 --- a/definitions/opencode/models/build.gradle.kts +++ b/definitions/opencode/models/build.gradle.kts @@ -7,7 +7,9 @@ plugins { alias(libs.plugins.protobuf.validate) } -val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) ":osx-x86_64" else "" +val archSuffix = if (Os.isFamily(Os.FAMILY_MAC)) { + if (System.getProperty("os.arch") == "aarch64") ":osx-aarch_64" else ":osx-x86_64" +} else "" version = "0.0.1" group = "com.codeinc.opencode.gen" diff --git a/gradle.properties b/gradle.properties index 6495379f6..d88faa8ee 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,4 +38,4 @@ android.defaults.buildfeatures.usestaticrclass=true android.experimental.enableTestFixturesKotlinSupport=true android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.experimental.enableTestFixturesKotlinSupport android.uniquePackageNames=false -android.dependency.useConstraints=true +android.dependency.useConstraints=false diff --git a/libs/locale/public/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt b/libs/locale/public/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt index ebc812e30..1d6ed0f35 100644 --- a/libs/locale/public/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt +++ b/libs/locale/public/src/main/kotlin/com/getcode/util/locale/LocaleUtils.kt @@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit object LocaleUtils { - fun getLanguageTag() = Locale.getDefault().toLanguageTag() + fun getLanguageTag(): String = Locale.getDefault().toLanguageTag() suspend fun getDefaultCountry(context: Context): String? { val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager diff --git a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/api/CoinbaseOnrampApi.kt b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/api/CoinbaseOnrampApi.kt index dd53e0983..c59557837 100644 --- a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/api/CoinbaseOnrampApi.kt +++ b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/api/CoinbaseOnrampApi.kt @@ -4,10 +4,12 @@ import com.coinbase.onramp.data.OnRampOrderResponse import com.coinbase.onramp.data.OnRampPurchaseResponse import com.coinbase.onramp.data.SessionTokenRequest import com.coinbase.onramp.data.SessionTokenResponse +import kotlinx.serialization.json.JsonObject import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.Query import retrofit2.http.Url interface CoinbaseApi { @@ -26,6 +28,14 @@ interface CoinbaseApi { @Header("Authorization") jwt: String, ): OnRampOrderResponse + @GET + suspend fun getBuyOptions( + @Url url: String, + @Header("Authorization") jwt: String, + @Query("country") country: String? = null, + @Query("subdivision") subdivision: String? = null, + ): JsonObject + @POST("/onramp/v1/token") suspend fun generateSessionToken( @Header("Authorization") jwt: String, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index 5c0e3f4f1..e8ca08e5f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt @@ -12,7 +12,9 @@ import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildSellInstructions import com.getcode.opencode.solana.swap.buildStablecoinSwapperInstructions import com.getcode.opencode.solana.swap.buildStatelessSwapInstructions +import com.getcode.opencode.solana.swap.buildUsdcDepositInstructions import com.getcode.opencode.solana.swap.buildUsdcToUsdfSwapInstructions +import com.getcode.opencode.solana.swap.buildUsdfDepositInstructions import com.getcode.opencode.model.transactions.StatelessSwapServerParameters import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey @@ -210,6 +212,75 @@ object TransactionBuilder { ) } + /** + * Constructs a Solana transaction that transfers USDC from an external wallet + * (e.g. Phantom) to the owner's USDC ATA. The server's auto-sweep will detect + * the deposit and convert it to USDF. + * + * @param owner The authority public key of the app wallet owner. + * @param sender The public key of the external wallet (Phantom). + * @param amount The amount of USDC to transfer (in quarks). + * @param blockhash A recent blockhash for the transaction. + * @return A constructed [SolanaTransaction] (V0) ready to be signed. + */ + fun usdcDeposit( + owner: PublicKey, + sender: PublicKey, + amount: Long, + blockhash: Hash?, + ): SolanaTransaction { + val instructions = buildUsdcDepositInstructions( + sender = sender, + owner = owner, + amount = amount, + ) + + return SolanaTransaction.newV0Instance( + payer = sender, + recentBlockhash = blockhash, + addressLookupTables = emptyList(), + instructions = instructions, + ) + } + + /** + * Constructs a Solana transaction that swaps USDC→USDF via the Coinbase + * Stable Swapper and deposits the USDF directly into the owner's USDF VM + * deposit PDA ATA. + * + * Designed for Phantom-signed deposits: the sender (Phantom wallet) pays + * for compute and ATA rent. The Geyser watcher detects USDF in the deposit + * PDA ATA and sweeps it into the VM — no server-side USDC sweep needed. + * + * @param owner The public key of the app wallet owner. + * @param sender The public key of the external wallet (Phantom). + * @param amount The amount of USDC to swap into USDF (in quarks). + * @param feeRecipient The Coinbase pool fee recipient address. + * @param blockhash A recent blockhash for the transaction. + * @return A constructed [SolanaTransaction] (V0) ready to be signed. + */ + fun usdfDeposit( + owner: PublicKey, + sender: PublicKey, + amount: Long, + feeRecipient: PublicKey, + blockhash: Hash?, + ): SolanaTransaction { + val instructions = buildUsdfDepositInstructions( + sender = sender, + owner = owner, + amount = amount, + feeRecipient = feeRecipient, + ) + + return SolanaTransaction.newV0Instance( + payer = sender, + recentBlockhash = blockhash, + addressLookupTables = emptyList(), + instructions = instructions, + ) + } + /** * Constructs a Solana transaction for a stateless USDC deposit sweep via the * Coinbase Stable Swapper program. diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositInstructions.kt new file mode 100644 index 000000000..f7c5dd0b5 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositInstructions.kt @@ -0,0 +1,52 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount +import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice +import com.getcode.opencode.internal.solana.programs.TokenProgram_Transfer +import com.getcode.opencode.solana.Instruction +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey + +/** + * Builds instructions for a simple USDC SPL token transfer from an external + * wallet ([sender]) to the app wallet [owner]'s USDC ATA. + * + * The server's USDC deposit sweep will detect the incoming balance and + * convert it to USDF automatically. + */ +internal fun buildUsdcDepositInstructions( + sender: PublicKey, + owner: PublicKey, + amount: Long, +): List { + val senderUsdcAta = PublicKey.deriveAssociatedAccount( + owner = sender, + mint = Mint.usdc, + ).publicKey + + val ownerUsdcAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = sender, + owner = owner, + mint = Mint.usdc, + ) + + return buildList { + // 1. ComputeBudget::SetComputeUnitLimit + add(ComputeBudgetProgram_SetComputeUnitLimit(units = 100_000).instruction()) + // 2. ComputeBudget::SetComputeUnitPrice + add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 1_000).instruction()) + // 3. AssociatedTokenAccount::CreateIdempotent (owner's USDC ATA) + add(ownerUsdcAta.instruction()) + // 4. TokenProgram::Transfer (sender USDC ATA → owner USDC ATA) + add( + TokenProgram_Transfer( + amount = amount, + source = senderUsdcAta, + destination = ownerUsdcAta.address, + owner = sender, + ).instruction() + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt new file mode 100644 index 000000000..0fd537cd8 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt @@ -0,0 +1,106 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount +import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount +import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount +import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts +import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice +import com.getcode.opencode.internal.solana.vmAuthority +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.usdf +import com.getcode.opencode.solana.Instruction +import com.getcode.opencode.solana.keys.TimelockDerivedAccounts +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey + +/** + * Builds instructions for a Phantom-signed USDC→USDF swap that deposits + * directly into the owner's USDF VM deposit PDA ATA. + * + * Instruction layout: + * 1. ComputeBudget::SetComputeUnitLimit(200_000) + * 2. ComputeBudget::SetComputeUnitPrice(1_000) + * 3. AssociatedTokenAccount::CreateIdempotent (owner's USDF VM deposit PDA ATA — Phantom pays rent) + * 4. CoinbaseStableSwapper::Swap (sender's USDC ATA → owner's USDF VM deposit PDA ATA) + * + * The Geyser watcher monitors the deposit PDA ATA and transfers USDF + * into the VM once funds land — same path as the stateless sweep. + */ +internal fun buildUsdfDepositInstructions( + sender: PublicKey, + owner: PublicKey, + amount: Long, + feeRecipient: PublicKey, +): List { + val usdfMeta = MintMetadata.usdf + val usdfVm = requireNotNull(usdfMeta.vmMetadata) + + // Sender's USDC ATA — source of funds in Phantom's wallet. + val senderUsdcAta = PublicKey.deriveAssociatedAccount( + owner = sender, + mint = Mint.usdc, + ).publicKey + + // Owner's USDF VM Deposit PDA. + val vmAccount = PublicKey.deriveVirtualMachineAccount( + mint = Mint.usdf, + authority = usdfVm.authority, + lockout = usdfVm.lockDurationInDays.toUByte(), + ) + val depositPda = PublicKey.deriveDepositAccount( + vm = vmAccount.publicKey, + depositor = owner, + ) + + // Owner's USDF VM Deposit ATA — the address Geyser watches. + val depositPdaUsdfAta = PublicKey.deriveAssociatedAccount( + owner = depositPda.publicKey, + mint = Mint.usdf, + ) + + // Coinbase Stable Swapper PDAs. + val swapAccounts = CoinbaseSwapAccounts.derive(Mint.usdc, Mint.usdf) + + val feeRecipientUsdcAta = swapAccounts.feeRecipientTokenAccount( + feeRecipient = feeRecipient, + fromMint = Mint.usdc, + ) + + return buildList { + // 1. ComputeBudget::SetComputeUnitLimit + add(ComputeBudgetProgram_SetComputeUnitLimit(units = 200_000).instruction()) + // 2. ComputeBudget::SetComputeUnitPrice + add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 1_000).instruction()) + // 3. AssociatedTokenAccount::CreateIdempotent (owner's USDF VM deposit PDA ATA) + add( + AssociatedTokenProgram_CreateIdempotent( + subsidizer = sender, + owner = depositPda.publicKey, + mint = Mint.usdf, + ).instruction() + ) + // 4. CoinbaseStableSwapper::Swap (sender's USDC ATA → owner's USDF VM deposit PDA ATA) + add( + CoinbaseStableSwapperProgram_Swap( + pool = swapAccounts.pool, + inVault = swapAccounts.inVault, + outVault = swapAccounts.outVault, + inVaultTokenAccount = swapAccounts.inVaultTokenAccount, + outVaultTokenAccount = swapAccounts.outVaultTokenAccount, + userFromTokenAccount = senderUsdcAta, + toTokenAccount = depositPdaUsdfAta.publicKey, + feeRecipientTokenAccount = feeRecipientUsdcAta, + feeRecipient = feeRecipient, + fromMint = Mint.usdc, + toMint = Mint.usdf, + user = sender, + whitelist = swapAccounts.whitelist, + amountIn = amount, + minAmountOut = amount, // 1:1 stable pair + ).instruction() + ) + } +} From 98e8dac94c97c9ecd4c3ca14fdb7f15e140dd516 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 11 Jun 2026 16:07:28 -0400 Subject: [PATCH 2/6] feat(deposit): show deposit toast on camera after successful deposit Extract public ToastController interface into core so features can trigger toasts independently of the bill-grab queue. Pass deposited amount through SwapResult.Success and fire the toast before popAll. --- .../kotlin/com/flipcash/app/MainActivity.kt | 6 +++++ .../kotlin/com/flipcash/app/core/AppRoute.kt | 1 + .../app/core/toast/ToastController.kt | 12 +++++++++ .../flipcash/app/core/tokens/SwapResult.kt | 3 ++- .../app/balance/internal/BalanceViewModel.kt | 2 +- .../app/menu/internal/MenuScreenViewModel.kt | 2 +- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 15 ++++++++--- .../app/tokens/TokenTxProcessingScreen.kt | 3 ++- .../app/payments/PurchaseMethodController.kt | 2 +- .../InternalPurchaseMethodController.kt | 8 +++--- .../flipcash/app/session/inject/CoreModule.kt | 6 +++++ .../session/internal/RealSessionController.kt | 6 ++--- .../session/internal/toast/ToastController.kt | 20 +++++++++++++-- .../SessionControllerGiftCardErrorTest.kt | 25 ++++--------------- .../app/tokens/TokenSelectionResolverTest.kt | 10 ++++---- 15 files changed, 80 insertions(+), 41 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/ToastController.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index 129fe8dff..da568d77b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -19,6 +19,8 @@ import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.LocalContactCoordinator import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.toast.LocalToastController +import com.flipcash.app.core.toast.ToastController import com.flipcash.app.core.verification.email.EmailCodeChannel import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.onramp.LocalCoinbaseOnRampController @@ -125,6 +127,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var contactCoordinator: ContactCoordinator + @Inject + lateinit var toastController: ToastController + @Inject lateinit var coinbaseOnRampController: CoinbaseOnRampController @@ -153,6 +158,7 @@ class MainActivity : FragmentActivity() { LocalAppUpdater provides appUpdater, LocalEmailCodeChannel provides emailCodeChannel, LocalContactCoordinator provides contactCoordinator, + LocalToastController provides toastController, LocalCoinbaseOnRampController provides coinbaseOnRampController, LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false), ) { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 781734011..4a5c34046 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -173,6 +173,7 @@ sealed interface AppRoute : NavKey, Parcelable { data class Swap( val purpose: SwapPurpose, val shortfall: Fiat? = null, + val popToRoot: Boolean = false, ) : Token, FlowRouteWithResult { override val initialStack: List get() = listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/ToastController.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/ToastController.kt new file mode 100644 index 000000000..2c087c8aa --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/toast/ToastController.kt @@ -0,0 +1,12 @@ +package com.flipcash.app.core.toast + +import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.opencode.model.financial.Fiat + +interface ToastController { + fun showToast(amount: Fiat, isDeposit: Boolean) +} + +val LocalToastController = staticCompositionLocalOf { + error("No ToastController provided") +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapResult.kt index 4b59c21bf..495ec0a4c 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapResult.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapResult.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.tokens import android.os.Parcelable +import com.getcode.opencode.model.financial.Fiat import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -8,7 +9,7 @@ import kotlinx.serialization.Serializable sealed interface SwapResult : Parcelable { @Parcelize @Serializable - data object Success : SwapResult + data class Success(val amount: Fiat = Fiat.Zero) : SwapResult @Parcelize @Serializable diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 044aa0c2a..632591677 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -54,7 +54,7 @@ internal class BalanceViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { purchaseMethodController.presentDepositOptions() } + .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 23b877474..794f60914 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -152,7 +152,7 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { purchaseMethodController.presentDepositOptions() } + .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 437fd3692..92363b17a 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -5,8 +5,10 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.toast.LocalToastController import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep +import com.getcode.opencode.model.financial.Fiat import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flowAnnotatedEntry @@ -23,6 +25,7 @@ fun SwapFlowScreen( resultStateRegistry: NavResultStateRegistry, ) { val outerNavigator = LocalCodeNavigator.current + val toastController = LocalToastController.current val initialStack = route.rememberInitialStack() FlowHost( @@ -39,9 +42,15 @@ fun SwapFlowScreen( value = NavResultOrCanceled.ReturnValue(result), ) when (result) { - SwapResult.Success -> { - if (route.shortfall != null) outerNavigator.popAll() - else outerNavigator.pop() + is SwapResult.Success -> { + if (route.shortfall != null || route.popToRoot) { + if (result.amount > Fiat.Zero) { + toastController.showToast(result.amount, isDeposit = true) + } + outerNavigator.popAll() + } else { + outerNavigator.pop() + } } SwapResult.OpenDeposit, SwapResult.Canceled -> outerNavigator.pop() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 1070f4d25..1fcea93e8 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -28,7 +28,8 @@ internal fun SwapProcessingScreen() { viewModel.eventFlow .filterIsInstance() .onEach { - flowNavigator.exitWithResult(SwapResult.Success) + val amount = viewModel.stateFlow.value.enteredAmount + flowNavigator.exitWithResult(SwapResult.Success(amount)) }.launchIn(this) } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt index 2803e7609..4b287a6de 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt @@ -9,5 +9,5 @@ interface PurchaseMethodController { val selections: Flow fun present(metadata: PurchaseMethodMetadata = PurchaseMethodMetadata()) fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) - suspend fun presentDepositOptions(): AppRoute? + suspend fun presentDepositOptions(popToRoot: Boolean = false): AppRoute? } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 12cdb52ca..bc03fdc18 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -122,7 +122,7 @@ class InternalPurchaseMethodController @Inject constructor( ) } - override suspend fun presentDepositOptions(): AppRoute? { + override suspend fun presentDepositOptions(popToRoot: Boolean): AppRoute? { delay(150) present(PurchaseMethodMetadata(mint = Mint.usdf, showReserves = false, canUseOtherWallets = true)) @@ -133,10 +133,12 @@ class InternalPurchaseMethodController @Inject constructor( return when (result) { PurchaseMethod.CoinbaseOnRamp -> AppRoute.Token.Swap( - purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase) + purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase), + popToRoot = popToRoot, ) PurchaseMethod.PhantomWallet -> AppRoute.Token.Swap( - purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Phantom) + purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Phantom), + popToRoot = popToRoot, ) PurchaseMethod.OtherWallet -> AppRoute.Transfers.Deposit(showOtherOptions = true) is PurchaseMethod.CashReserves -> null diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt index 1c518947d..c0bc713ef 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt @@ -1,7 +1,9 @@ package com.flipcash.app.session.inject +import com.flipcash.app.core.toast.ToastController import com.flipcash.app.session.SessionController import com.flipcash.app.session.internal.RealSessionController +import com.flipcash.app.session.internal.toast.SessionToastController import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,4 +16,8 @@ abstract class SessionModule { @Binds @Singleton abstract fun bindSessionController(impl: RealSessionController): SessionController + + @Binds + @Singleton + abstract fun bindToastController(impl: SessionToastController): ToastController } \ No newline at end of file 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 d5f4650c6..2558bd2fd 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 @@ -25,7 +25,7 @@ import com.flipcash.app.session.Grabbed import com.flipcash.app.session.PutInWallet import com.flipcash.app.session.SessionController import com.flipcash.app.session.SessionState -import com.flipcash.app.session.internal.toast.ToastController +import com.flipcash.app.session.internal.toast.SessionToastController import com.flipcash.app.shareable.ShareConfirmationResult import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController @@ -125,7 +125,7 @@ class RealSessionController @Inject constructor( private val profileUpdater: ProfileUpdater, private val shareSheetController: ShareSheetController, private val shareConfirmationController: ShareableConfirmationController, - private val toastController: ToastController, + private val toastController: SessionToastController, private val billingClient: BillingClient, private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, @@ -782,7 +782,7 @@ class RealSessionController @Inject constructor( override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { scope.launch { - purchaseMethodController.presentDepositOptions()?.let { onRoute?.invoke(it) } + purchaseMethodController.presentDepositOptions(popToRoot = true)?.let { onRoute?.invoke(it) } } } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt index 6ce249c27..f36e8da4f 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt @@ -2,6 +2,8 @@ package com.flipcash.app.session.internal.toast import com.flipcash.app.core.bill.BillToast import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.core.toast.ToastController +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -15,9 +17,9 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Singleton -class ToastController @Inject constructor( +class SessionToastController @Inject constructor( private val billController: BillController -) { +) : ToastController { companion object { val INITIAL_DELAY = 500.milliseconds val SHOW_DELAY = 3.seconds @@ -107,4 +109,18 @@ class ToastController @Inject constructor( } isConsumingQueue = false } + + override fun showToast(amount: Fiat, isDeposit: Boolean) { + if (amount.decimalValue == 0.0) return + scope.launch { + delay(INITIAL_DELAY) + billController.update { + it.copy(showToast = true, toast = BillToast(amount = amount, isDeposit = isDeposit)) + } + delay(SHOW_DELAY) + billController.update { it.copy(showToast = false) } + delay(INITIAL_DELAY) + billController.update { it.copy(toast = null) } + } + } } \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 6d9d198fd..e1f3fca25 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -1,39 +1,24 @@ package com.flipcash.app.session.internal -import com.flipcash.app.activityfeed.ActivityFeedCoordinator -import com.flipcash.app.activityfeed.ActivityFeedUpdater import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.internal.bill.BillController -import com.flipcash.app.session.internal.toast.ToastController -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.ShareResult +import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable -import com.flipcash.app.shareable.ShareableConfirmationController import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.app.tokens.TokenUpdater -import com.flipcash.services.controllers.AccountController -import com.flipcash.app.core.internal.updater.ProfileUpdater -import com.flipcash.services.controllers.SettingsController import com.flipcash.services.user.UserManager import com.flipcash.shared.session.R import com.getcode.manager.BottomBarManager -import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError import com.getcode.opencode.model.accounts.AccountCluster -import com.flipcash.app.core.bill.Bill -import com.flipcash.app.core.bill.BillState +import com.getcode.opencode.model.accounts.GiftCardAccount import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper -import com.flipcash.app.billing.BillingClient -import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.payments.PurchaseMethodController import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.util.vibration.Vibrator -import com.getcode.opencode.model.accounts.GiftCardAccount import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenSelectionResolverTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenSelectionResolverTest.kt index 8727d6616..5107afa32 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenSelectionResolverTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenSelectionResolverTest.kt @@ -148,10 +148,10 @@ class TokenSelectionResolverTest { // endregion - // region USDF exclusion + // region USDF selection @Test - fun `never selects USDF as fallback`() { + fun `selects USDF as fallback when it has highest balance`() { val result = resolveTokenSelection( balances = mapOf( mintA to Fiat(0.0, CurrencyCode.USD), @@ -160,11 +160,11 @@ class TokenSelectionResolverTest { currentSelection = mintA, rate = usdRate, ) - assertNull(result) + assertEquals(Mint.usdf, result) } @Test - fun `skips USDF and selects next highest`() { + fun `selects USDF over lower balance tokens`() { val result = resolveTokenSelection( balances = mapOf( mintA to Fiat(0.0, CurrencyCode.USD), @@ -174,7 +174,7 @@ class TokenSelectionResolverTest { currentSelection = mintA, rate = usdRate, ) - assertEquals(mintB, result) + assertEquals(Mint.usdf, result) } @Test From b9f67f5a2348c9d931cb17050d68fef15faab2d5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 11 Jun 2026 22:56:26 -0400 Subject: [PATCH 3/6] feat(deposit): add early verification gate and deposit-specific titles Check phone/email verification before proceeding with Google Pay purchases, both in the deposit flow (presentDepositOptions) and the swap flow (SwapViewModel). On successful verification the swap entry auto-retries via navigateForResult. Fix sheet not dismissing after OK on the processing screen by following popAll() with pop() to trigger onRootReached. Show "Amount to Deposit" / "Depositing USDF" titles when the funding source is external. --- .../core/src/main/res/values/strings.xml | 1 + .../internal/VerificationFlowIntroScreen.kt | 23 +++++++++++++++++ .../flipcash/app/tokens/SwapEntryScreen.kt | 17 +++++++++++-- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 4 +-- .../internal/TokenTxProcessingScreen.kt | 6 ++++- .../flipcash/shared/payments/build.gradle.kts | 1 + .../InternalPurchaseMethodController.kt | 25 ++++++++++++++++--- .../flipcash/app/tokens/ui/SwapViewModel.kt | 11 +++++++- 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c9800e48a..4b40afc34 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -491,6 +491,7 @@ Solana USDC with Sell %1$s Purchasing %1$s + Depositing %1$s Selling %1$s Review the above before confirming.\nOnce made, your transaction is irreversible. diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt index 68b218cb2..d45a2b14b 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier @@ -28,8 +29,12 @@ import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.core.verification.VerificationStep import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.contact.verification.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @@ -56,10 +61,28 @@ private fun VerificationFlowIntroScreenContent( isForOnRamp: Boolean, onClick: () -> Unit, ) { + val navigator = LocalOuterCodeNavigator.current + val isSheetRoot = remember { navigator.backStack.size <= 1 } CodeScaffold( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars), + topBar = { + if (isSheetRoot) { + AppBarWithTitle( + isInModal = true, + endContent = { + AppBarDefaults.Close { navigator.hide() } + }, + ) + } else { + AppBarWithTitle( + isInModal = true, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + } + }, bottomBar = { CodeButton( modifier = Modifier diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 9dab9fff2..f8e3a7d68 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -10,16 +10,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.onramp.CoinbaseOnRampCompletion import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.features.tokens.R +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult import com.getcode.opencode.model.financial.Fiat import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance @@ -33,6 +38,7 @@ internal fun SwapEntryScreen( initialAmount: Fiat? = null, ) { val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val coinbaseOnRampController = LocalCoinbaseOnRampController.current @@ -44,6 +50,8 @@ internal fun SwapEntryScreen( AppBarWithTitle( isInModal = true, title = when (purpose) { + is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> + stringResource(R.string.title_amountToDeposit) is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) }, @@ -97,13 +105,18 @@ internal fun SwapEntryScreen( .filterIsInstance() .onEach { (phone, email) -> val mint = (viewModel.stateFlow.value.purpose as? SwapPurpose.Buy)?.mint ?: return@onEach - flowNavigator.navigate( + navigator.navigateForResult( AppRoute.Verification( origin = AppRoute.Token.Swap(SwapPurpose.Buy(mint)), includePhone = phone, includeEmail = email, ) - ) + ) { result -> + if (result is NavResultOrCanceled.ReturnValue && + result.value is VerificationResult.Success) { + viewModel.dispatchEvent(SwapViewModel.Event.OnAmountConfirmed) + } + } }.launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 92363b17a..7bf26159f 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -48,9 +48,9 @@ fun SwapFlowScreen( toastController.showToast(result.amount, isDeposit = true) } outerNavigator.popAll() - } else { - outerNavigator.pop() } + // pop() at root triggers onRootReached → sheet dismiss + outerNavigator.pop() } SwapResult.OpenDeposit, SwapResult.Canceled -> outerNavigator.pop() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt index 880e9d71b..327c19e69 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.ui.buildNotifyButtonLabel import com.flipcash.app.core.ui.processing.FlowProcessingScreen @@ -54,7 +55,10 @@ private fun TokenTxProcessingScreen( topBar = { AppBarWithTitle( isInModal = true, - title = when (state.purpose) { + title = when (val purpose = state.purpose) { + is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> + stringResource(R.string.title_depositingToken, state.tokenName) + is SwapPurpose.BalanceIncrease -> stringResource( R.string.title_purchasingToken, state.tokenName diff --git a/apps/flipcash/shared/payments/build.gradle.kts b/apps/flipcash/shared/payments/build.gradle.kts index 0a7244e44..b201ce60b 100644 --- a/apps/flipcash/shared/payments/build.gradle.kts +++ b/apps/flipcash/shared/payments/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(project(":apps:flipcash:shared:tokens:core")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:messaging")) + implementation(project(":services:flipcash")) } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index bc03fdc18..be44f5e81 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -14,6 +14,7 @@ import com.flipcash.app.tokens.core.ReservesBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.internal.model.thirdparty.OnRampType +import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.exchange.Exchange @@ -49,6 +50,7 @@ class InternalPurchaseMethodController @Inject constructor( reservesBalanceProvider: ReservesBalanceProvider, exchange: Exchange, private val resources: ResourceHelper, + private val userManager: UserManager, ) : PurchaseMethodController { private val scope = CoroutineScope(SupervisorJob()) @@ -132,10 +134,25 @@ class InternalPurchaseMethodController @Inject constructor( ).first() return when (result) { - PurchaseMethod.CoinbaseOnRamp -> AppRoute.Token.Swap( - purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase), - popToRoot = popToRoot, - ) + PurchaseMethod.CoinbaseOnRamp -> { + val profile = userManager.profile + val needsPhone = profile?.verifiedPhoneNumber == null + val needsEmail = profile?.verifiedEmailAddress == null + val swapRoute = AppRoute.Token.Swap( + purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase), + popToRoot = popToRoot, + ) + if (needsPhone || needsEmail) { + AppRoute.Verification( + origin = swapRoute, + includePhone = needsPhone, + includeEmail = needsEmail, + target = swapRoute, + ) + } else { + swapRoute + } + } PurchaseMethod.PhantomWallet -> AppRoute.Token.Swap( purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Phantom), popToRoot = popToRoot, diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index f1bac6343..eeb69fb9c 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -86,7 +86,7 @@ data class AmountEntryState( @HiltViewModel class SwapViewModel @Inject constructor( - userManager: UserManager, + private val userManager: UserManager, private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, transactionController: TransactionOperations, @@ -795,6 +795,15 @@ class SwapViewModel @Inject constructor( PurchaseMethod.CoinbaseOnRamp -> { analytics.buttonTapped(Button.TokenBuyWithCoinbase) dispatchEvent(Event.CoinbaseSelected) + + val profile = userManager.profile + val needsPhone = profile?.verifiedPhoneNumber == null + val needsEmail = profile?.verifiedEmailAddress == null + if (needsPhone || needsEmail) { + dispatchEvent(Event.OnVerificationNeeded(needsPhone, needsEmail)) + return@onEach + } + val amount = metadata.purchaseAmount ?: return@onEach if (amount < minimumCoinbasePurchaseAmount) { From 09c03d0e4b5a19d9a5f1dd2cf2735101dcbdde0d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 12 Jun 2026 10:42:46 -0400 Subject: [PATCH 4/6] chore(token/info): update button options for USDF; trigger deposit flow here as well Signed-off-by: Brandon McAnsh --- .../app/tokens/internal/TokenInfoScreen.kt | 36 ++++++++++--------- apps/flipcash/shared/tokens/build.gradle.kts | 1 + .../app/tokens/ui/TokenInfoViewModel.kt | 9 +++++ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index bee7536f2..12f8b82f2 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -322,21 +322,20 @@ private fun RowScope.ReserveButtonOptions( state: TokenInfoViewModel.State, dispatch: (TokenInfoViewModel.Event) -> Unit ) { - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_deposit), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Deposit() - ) - ) - } - val hasBalance = state.balance.nativeAmount.isPositive if (hasBalance) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_give), + ) { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) + ) + ) + } CodeButton( modifier = Modifier.weight(1f), buttonState = ButtonState.Filled20, @@ -349,6 +348,14 @@ private fun RowScope.ReserveButtonOptions( ) } } + + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled20, + text = stringResource(R.string.action_deposit), + ) { + dispatch(TokenInfoViewModel.Event.PresentDepositOptions) + } } @Composable @@ -377,10 +384,7 @@ private fun RowScope.ButtonOptions( ) { dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give( - mint = mint, - fromTokenInfo = true - ) + AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) ) ) } diff --git a/apps/flipcash/shared/tokens/build.gradle.kts b/apps/flipcash/shared/tokens/build.gradle.kts index fdf2a3afd..dc744b5ae 100644 --- a/apps/flipcash/shared/tokens/build.gradle.kts +++ b/apps/flipcash/shared/tokens/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:userflags")) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index e47df3c7e..540d1c7b9 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -48,6 +48,7 @@ class TokenInfoViewModel @Inject constructor( private val exchange: Exchange, private val shareController: ShareSheetController, private val resources: ResourceHelper, + private val purchaseMethodController: PurchaseMethodController, features: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( @@ -95,6 +96,7 @@ class TokenInfoViewModel @Inject constructor( data class ExpandDescription(val expand: Boolean) : Event data object Share : Event data class OnBuy(val shortFall: Fiat? = null) : Event + data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object Exit : Event } @@ -272,6 +274,12 @@ class TokenInfoViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .mapNotNull { purchaseMethodController.presentDepositOptions(popToRoot = true) } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.token.dataOrNull } @@ -290,6 +298,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnBalanceUpdated -> { state -> state.copy(balance = event.balance) } is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.amount) } is Event.ExpandDescription -> { state -> state.copy(descriptionExpanded = event.expand) } + is Event.PresentDepositOptions -> { state -> state } is Event.OnHistoricalMarketCapDataUpdated -> { state -> val historicalData = state.historicalMarketCapData.toMutableMap() historicalData[event.period] = event.data From b423ad9b321949e7a8071ac8288942b65ff8f2b3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 12 Jun 2026 10:52:08 -0400 Subject: [PATCH 5/6] chore(deposit): change the purchase option modal title when using for deposit Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../com/flipcash/app/payments/PurchaseMethod.kt | 2 ++ .../InternalPurchaseMethodController.kt | 17 +++++++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4b40afc34..0aafbf557 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -509,6 +509,7 @@ 1Y this year + Select Method Select Purchase Method USDF (%1$s) Debit Card with diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt index 9a9b89d26..6f4515bef 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt @@ -5,6 +5,7 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint enum class PaymentAction { Buy, Pay } +enum class PurchasePurpose { Buy, Deposit } sealed interface PurchaseMethod { data object CoinbaseOnRamp : PurchaseMethod @@ -19,6 +20,7 @@ data class PurchaseMethodMetadata( val feeAmount: Fiat? = null, val showReserves: Boolean = true, val paymentAction: PaymentAction = PaymentAction.Buy, + val purpose: PurchasePurpose = PurchasePurpose.Buy, val canUseOtherWallets: Boolean = false, ) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index be44f5e81..345b6bca6 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -10,6 +10,7 @@ import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.payments.PurchaseMethodSelection import com.flipcash.app.payments.PurchaseMethodState +import com.flipcash.app.payments.PurchasePurpose import com.flipcash.app.tokens.core.ReservesBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -103,9 +104,16 @@ class InternalPurchaseMethodController @Inject constructor( override fun present(metadata: PurchaseMethodMetadata) { _state.update { it.copy(canUseOtherWallets = metadata.canUseOtherWallets) } + var selected = false + + val title = when (metadata.purpose) { + PurchasePurpose.Buy -> resources.getString(R.string.prompt_title_selectPurchaseMethod) + PurchasePurpose.Deposit -> resources.getString(R.string.prompt_title_selectMethod) + } + BottomBarManager.showMessage( - title = resources.getString(R.string.prompt_title_selectPurchaseMethod), + title = title, actions = purchaseOptions(_state.value, metadata, resources) { method -> selected = true scope.launch { @@ -126,7 +134,12 @@ class InternalPurchaseMethodController @Inject constructor( override suspend fun presentDepositOptions(popToRoot: Boolean): AppRoute? { delay(150) - present(PurchaseMethodMetadata(mint = Mint.usdf, showReserves = false, canUseOtherWallets = true)) + present(PurchaseMethodMetadata( + mint = Mint.usdf, + purpose = PurchasePurpose.Deposit, + showReserves = false, + canUseOtherWallets = true) + ) val result = merge( selections.map { it.method }, From 5708415b3ffcc03a1ee3f54344ff99fc77e1ced7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 12 Jun 2026 11:31:21 -0400 Subject: [PATCH 6/6] chore: remove dead rxjava toml definitions Signed-off-by: Brandon McAnsh --- gradle/libs.versions.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f1aa070d..95c7be83f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -195,10 +195,6 @@ compose-view-models = { module = "androidx.lifecycle:lifecycle-viewmodel-compose compose-paging = { module = "androidx.paging:paging-compose", version.ref = "compose-paging" } compose-webview = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "compose-webview" } -# RxJava -rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" } -rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" } - # Networking / gRPC / Protobuf slf4j = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } grpc-android = { module = "io.grpc:grpc-android", version.ref = "grpc-android" }