diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/CurrencyCreatorStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/CurrencyCreatorStep.kt index 9d2642969..eaa999df8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/CurrencyCreatorStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/CurrencyCreatorStep.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import com.getcode.navigation.NonDismissableRoute import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.flow.FlowStep +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.ui.TokenBillCustomizations import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -44,7 +45,11 @@ sealed interface CurrencyCreatorStep: FlowStep, Parcelable, NonDismissableRoute, @Parcelize @Serializable - data object BillReviewAndPurchase: CurrencyCreatorStep + data object BillReview: CurrencyCreatorStep + + @Parcelize + @Serializable + data object FundingSourceSelection: CurrencyCreatorStep @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 9fd43361e..2ace76ca5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -2,7 +2,6 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -11,7 +10,8 @@ import kotlinx.serialization.Serializable @Parcelize sealed interface TokenPurpose: Parcelable { @Serializable data object Select : TokenPurpose - @Serializable data class Purchase(val desiredToken: Mint, val amount: Fiat) : TokenPurpose + @Serializable data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose + @Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose @Serializable data object Withdraw: TokenPurpose @Serializable data object Deposit: TokenPurpose @Serializable data object Balance : TokenPurpose diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f4bdc8ed4..9ed1b3335 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -489,6 +489,9 @@ Currency Name $BadBoys Pay %1$s to Create + Ready To Create? + You won\'t be able to change the name once your currency is created. + Pay To Create Currency Creating %1$s %1$s Is Live Your currency is ready to receive and use diff --git a/apps/flipcash/features/currency-creator/build.gradle.kts b/apps/flipcash/features/currency-creator/build.gradle.kts index 9d369e056..e8a585212 100644 --- a/apps/flipcash/features/currency-creator/build.gradle.kts +++ b/apps/flipcash/features/currency-creator/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:features:bill-customization")) + implementation(project(":apps:flipcash:features:tokens")) implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:currency-creator")) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt index 7f9387515..33dbd589c 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt @@ -2,7 +2,6 @@ package com.flipcash.app.currencycreator import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,10 +44,10 @@ import com.flipcash.app.currencycreator.internal.screens.NameSelectionContent import com.flipcash.app.currencycreator.internal.screens.NameSelectionScreen import com.flipcash.app.currencycreator.internal.screens.ProcessingContent import com.flipcash.app.currencycreator.internal.screens.ProcessingScreen -import com.flipcash.app.currencycreator.internal.screens.ReviewAndPurchaseContent -import com.flipcash.app.currencycreator.internal.screens.ReviewAndPurchaseScreen +import com.flipcash.app.currencycreator.internal.screens.ReviewContent +import com.flipcash.app.currencycreator.internal.screens.BillReviewScreen +import com.flipcash.app.currencycreator.internal.screens.FundingSourceSelectionScreen import com.flipcash.app.session.LocalSessionController -import com.flipcash.app.theme.FlipcashPreview import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.navigation.annotatedEntry @@ -96,6 +95,21 @@ fun CurrencyCreatorFlowScreen( ) } } + + // TokenSelectScreen's own top bar is hidden on this step (showTopBar = false) + // to avoid stacking two bars; surface its title here instead. Matches the + // LaunchFunding title the select screen would show, and like Bill Review this + // step shows no progress indicator (title replaces the progress slot). + is CurrencyCreatorStep.FundingSourceSelection -> { + { + Text( + text = stringResource(R.string.title_selectPaymentCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + } + } + is CurrencyCreatorStep.Processing -> { { val text = if (state.processingState.success) { @@ -185,7 +199,8 @@ private fun currencyCreatorEntryProvider(): (NavKey) -> NavEntry = entry annotatedEntry { SyncTopBar(it); IconSelectionScreen() } annotatedEntry { SyncTopBar(it); DescriptionSelectionScreen() } annotatedEntry { SyncTopBar(it); BillCustomizationScreen() } - annotatedEntry { SyncTopBar(it); ReviewAndPurchaseScreen() } + annotatedEntry { SyncTopBar(it); BillReviewScreen() } + annotatedEntry { SyncTopBar(it); FundingSourceSelectionScreen() } annotatedEntry { SyncTopBar(it); ProcessingScreen() } } @@ -213,7 +228,7 @@ private fun SyncTopBar(step: CurrencyCreatorStep) { topBar.onEndAction = { val bill = billPlaygroundController.state.value.customizedBill viewModel.dispatchEvent(CurrencyCreatorViewModel.Event.OnBillConfirmed(bill)) - flowNavigator.navigateTo(CurrencyCreatorStep.BillReviewAndPurchase) + flowNavigator.navigateTo(CurrencyCreatorStep.BillReview) } } } @@ -286,7 +301,7 @@ private fun Preview_Bill() { @PreviewWrapper(FlipcashThemeWrapper::class) @Composable private fun Preview_Review() { - CurrencyCreatorPreview { ReviewAndPurchaseContent(it) {} } + CurrencyCreatorPreview { ReviewContent(it) {} } } @Preview 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 0e0096d70..4178ebd9f 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 @@ -12,17 +12,7 @@ import com.flipcash.app.core.tokens.CurrencyCreatorDraft import com.flipcash.app.core.tokens.CurrencyCreatorStep 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 -import com.flipcash.app.onramp.messaging -import com.flipcash.app.payments.PaymentAction -import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController -import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator @@ -39,18 +29,14 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.CurrencyController -import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.core.errors.CheckTokenAvailabilityError import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError -import com.getcode.opencode.model.core.errors.GetMintsError -import com.getcode.opencode.model.core.errors.LaunchTokenError import com.getcode.opencode.model.core.errors.ValidationException import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenCreateRequest @@ -60,7 +46,6 @@ import com.getcode.opencode.model.financial.plus import com.getcode.opencode.model.financial.toFiat import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.moderation.ModerationAttestation -import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.solana.keys.Mint import com.getcode.util.resources.ContentReader @@ -77,6 +62,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -100,9 +87,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( userFlags: UserFlagsCoordinator, moderationController: ModerationController, currencyController: CurrencyController, - transactionController: TransactionController, private val verifiedFiatCalculator: VerifiedFiatCalculator, - private val phantomWalletController: PhantomWalletController, tokenCoordinator: TokenCoordinator, balancePoller: BalancePoller, private val resources: ResourceHelper, @@ -168,7 +153,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( CurrencyCreatorStep.IconSelection::class, CurrencyCreatorStep.DescriptionSelection::class, CurrencyCreatorStep.BillCustomization::class, - CurrencyCreatorStep.BillReviewAndPurchase::class, + CurrencyCreatorStep.BillReview::class, ) } } @@ -198,12 +183,10 @@ internal class CurrencyCreatorViewModel @Inject constructor( data class CustomizationsChanged(val customizations: TokenBillCustomizations): Event - data class LaunchToken(val method: PurchaseMethod) : Event + data class LaunchToken(val fundedWith: Mint) : Event data class OnTokenMinted(val mint: Mint): Event data object Purchase : Event - data class PurchaseWithReserves(val context: LaunchedContext) : Event - data class PurchaseWithPhantom(val context: LaunchedContext) : Event - data class PurchaseWithGooglePay(val context: LaunchedContext) : Event + data class ConfirmPurchase(val fundedWith: Mint): Event data class PurchaseSubmitted(val swapId: SwapId, val mint: Mint) : Event data class PurchaseCompleted(val token: Token): Event @@ -214,13 +197,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( data class OpenScreen(val screen: AppRoute) : Event } - data class LaunchedContext( - val method: PurchaseMethod, - val token: Token, - val amount: Fiat, - val feeAmount: Fiat?, - ) - init { userFlags.resolvedFlags .onEach { flags -> @@ -230,13 +206,74 @@ internal class CurrencyCreatorViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + if (addMoney) { + // A currency can now be funded by any held currency, not just USDF reserves. + // No balance at all gates first; having some balance but none large enough to + // cover the cost gates second. + val totalCost = stateFlow.value.totalCost + val balances = tokenCoordinator.tokenBalances.firstOrNull().orEmpty().map { it.balance } + + if (balances.none { it.hasDisplayableValue }) { + // No balance in any currency — require a deposit first. + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = resources.getString(R.string.description_noBalanceYetToCreate), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + return@onEach + } else if (balances.none { it > totalCost }) { + // Has balance, but no single currency covers the cost — require a top-up. + BottomBarManager.showInfo( + title = resources.getString(R.string.title_insufficientBalance), + message = resources.getString(R.string.description_insufficientBalanceToCreate), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoreMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + return@onEach + } + } + + dispatchEvent(Event.AdvanceFromInfo) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { + if (!featureFlags.get(FeatureFlag.AddMoneyUX)) { + return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) + } + // popToRoot = false so finishing the deposit returns to the currency + // creator (which pushed this flow) rather than tearing down the whole + // sheet and losing the user's place in the flow. + purchaseMethodController.presentDepositOptions(popToRoot = false) + } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) + // Debounced draft persistence — save state 300ms after changes. // The coordinator handles ID assignment; the VM is ID-unaware. @OptIn(FlowPreview::class) stateFlow .drop(1) // skip initial empty state .filter { it.currentStep != null } - .debounce(300) + .debounce(300.milliseconds) .distinctUntilChanged() .onEach { state -> currencyCreatorCoordinator.saveDraft( @@ -346,7 +383,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( onSuccess = { attestation -> viewModelScope.launch { dispatchEvent(Event.UpdateProcessingState(success = true)) - delay(500) + delay(500.milliseconds) dispatchEvent(Event.OnImageApproved(attestation)) dispatchEvent(Event.UpdateProcessingState()) } @@ -398,7 +435,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( onSuccess = { attestation -> viewModelScope.launch { dispatchEvent(Event.UpdateProcessingState(success = true)) - delay(500) + delay(500.milliseconds) dispatchEvent(Event.OnDescriptionApproved(attestation)) dispatchEvent(Event.UpdateProcessingState()) } @@ -427,38 +464,81 @@ internal class CurrencyCreatorViewModel @Inject constructor( .launchIn(viewModelScope) eventFlow - .filterIsInstance() - .onEach { - val metadata = PurchaseMethodMetadata( - mint = null, - purchaseAmount = stateFlow.value.totalCost, - feeAmount = stateFlow.value.feeAmount, - paymentAction = PaymentAction.Pay, - ) - val reservesBalance = tokenCoordinator.reservesBalance() - val rate = exchange.preferredRate - val localizedBalance = LocalFiat( - usdf = reservesBalance, - nativeAmount = reservesBalance.convertingTo(rate), + .filterIsInstance() + .onEach { event -> + // Confirm before paying — creating a currency is irreversible and the name locks in. + BottomBarManager.showMessage( + title = resources.getString(R.string.title_readyToCreate), + message = resources.getString(R.string.description_readyToCreate), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_payToCreateCurrency), + ) { + dispatchEvent(Event.LaunchToken(event.fundedWith)) + }, + ), + showCancel = true, ) - purchaseMethodController.select(PurchaseMethod.CashReserves(localizedBalance), metadata) - } - .launchIn(viewModelScope) - - purchaseMethodController.selections - // Only the final "pay for the created token" selection should launch the - // token. The shared controller also emits for the add-money/deposit sheet - // (PaymentAction.Plain); reacting to those would try to launch a token with - // an empty, un-moderated name and fail name validation. - .filter { (_, metadata) -> metadata.paymentAction == PaymentAction.Pay } - .onEach { (method, _) -> - dispatchEvent(Event.LaunchToken(method)) } .launchIn(viewModelScope) eventFlow .filterIsInstance() - .map { event -> + .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } + .mapNotNull { event -> + val accountCluster = userManager.accountCluster ?: run { + dispatchEvent(Event.UpdateProcessingState()) + return@mapNotNull null + } + val fundingToken = tokenCoordinator.getTokenMetadata(event.fundedWith) + .getOrNull()?.token ?: run { + dispatchEvent(Event.UpdateProcessingState()) + return@mapNotNull null + } + + // Value the launch at the full total cost against the funding token: USDF prices 1:1, + // a launchpad currency prices through its bonding curve. The amount is NOT grossed up + // for the pool sell fee — the treasury-funded launch covers it, and the server + // requires the full amount to value at exactly the total cost. Priced in USD: the + // server requires the full-amount exchange data (and its verified rate) to be USD, + // not the user's preferred currency. launchToken routes buy vs swap from the token. + val isUsdf = fundingToken.address == Mint.usdf + val rate = if (isUsdf) Rate.oneToOne else exchange.rateForUsd() + val amount = verifiedFiatCalculator.compute( + amount = stateFlow.value.totalCost, + token = fundingToken, + rate = rate, + ).getOrElse { + dispatchEvent(Event.UpdateProcessingState()) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@mapNotNull null + } + + // The pool fee, in FUNDING-TOKEN quarks: the server sells them against the reserve + // and expects the fee's USD value. Compute it against the funding token, like the + // amount. (USDF funds a plain buy, whose fee stays USD.) + val feeAmount = stateFlow.value.feeAmount?.let { fee -> + if (isUsdf) { + LocalFiat.fromUsd(usdf = fee) + } else { + verifiedFiatCalculator.compute( + amount = fee, + token = fundingToken, + rate = rate, + ).getOrElse { + dispatchEvent(Event.UpdateProcessingState()) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@mapNotNull null + }.localFiat + } + } + val request = TokenCreateRequest( name = ModerationAttestation.Text( text = stateFlow.value.nameFieldState.text.trim().toString(), @@ -475,133 +555,29 @@ internal class CurrencyCreatorViewModel @Inject constructor( attestation = stateFlow.value.attestations.icon.rawValue, ), bill = stateFlow.value.customizations, + funding = TokenCreateRequest.Funding( + token = fundingToken, + amount = amount, + // The exact USD launch cost; the service pins the swap's native value to it. + fullAmount = stateFlow.value.totalCost, + feeAmount = feeAmount, + ), ) - request to event.method - } - .mapNotNull { (request, method) -> - val accountCluster = userManager.accountCluster ?: return@mapNotNull null - Triple(request, method, accountCluster) - } - .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } - .map { (request, method, accountCluster) -> - currencyController.launchToken(request, accountCluster.authority.keyPair) - .recoverCatching { cause -> - // The server returns Exists when the mint address is already - // registered. If we minted in a prior attempt this session - // but the subsequent purchase failed (e.g. transient OCP - // error), the mint exists on-chain yet has no funded token - // account. Recover the mint so the purchase step can retry. - if (cause !is LaunchTokenError.Exists) throw cause - val mint = stateFlow.value.createdMint ?: throw cause - val notFound = tokenCoordinator.getTokenMetadata(mint) - .exceptionOrNull() is GetMintsError.NotFound - if (!notFound) throw cause - mint - } - .map { mint -> - dispatchEvent(Event.OnTokenMinted(mint)) - val token = MintMetadata.fromLaunch( - mint = mint, - request = request, - owner = accountCluster.authorityPublicKey, - ) - LaunchedContext( - method = method, - token = token, - amount = stateFlow.value.totalCost, - feeAmount = stateFlow.value.feeAmount, - ) - } + accountCluster to request } - .onResult( - onSuccess = { ctx -> - when (ctx.method) { - is PurchaseMethod.CashReserves -> - dispatchEvent(Event.PurchaseWithReserves(ctx)) - - PurchaseMethod.PhantomWallet -> - dispatchEvent(Event.PurchaseWithPhantom(ctx)) - - PurchaseMethod.CoinbaseOnRamp -> { - dispatchEvent(Event.PurchaseWithGooglePay(ctx)) - } - - PurchaseMethod.OtherWallet -> { - // TODO: - } - } - }, - onError = { - dispatchEvent(Event.UpdateProcessingState()) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_launchTokenFailed), - message = resources.getString(R.string.error_description_launchTokenFailed), - ) - } - ) - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { event -> - val totalAmount = verifiedFiatCalculator.compute( - amount = event.context.amount, - token = Token.usdf, - rate = Rate.oneToOne, - ).getOrElse { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), - ) - return@onEach - } - - val feeAmount = event.context.feeAmount?.let { LocalFiat.fromUsd(usdf = it) } - ?: LocalFiat.Zero - val token = event.context.token - - viewModelScope.launch { - phantomWalletController.connectAndSwap( - amount = totalAmount, - fee = feeAmount, - token = token, - ).onSuccess { result -> - val swapId = (result as PhantomSwapResult.WithSwapId).swapId - dispatchEvent(Event.PurchaseSubmitted(swapId, token.address)) - }.onFailure { error -> - handlePhantomError(error) - } - } - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { event -> - val owner = userManager.accountCluster ?: return@mapNotNull null - Pair(owner, event.context) - } - .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } - .map { (owner, context) -> - verifiedFiatCalculator.compute( - amount = context.amount, - token = Token.usdf, - rate = Rate.oneToOne, - ).mapCatching { totalAmount -> - val feeAmount = context.feeAmount?.let { LocalFiat.fromUsd(usdf = it) } - transactionController.buy( - owner = owner, - amount = totalAmount, - feeAmount = feeAmount, - of = context.token, - source = SwapFundingSource.SubmitIntent(), - fund = null, - ).getOrThrow() - }.map { swapId -> swapId to context.token.address } + .map { (accountCluster, request) -> + currencyController.launchToken( + request = request, + owner = accountCluster, + // Recover a prior-attempt mint whose funding failed, so the retry re-funds + // rather than failing on Exists. + existingMint = stateFlow.value.createdMint, + onMinted = { dispatchEvent(Event.OnTokenMinted(it)) }, + ) } .onResult( - onSuccess = { (swapId, mint) -> - dispatchEvent(Event.PurchaseSubmitted(swapId, mint)) + onSuccess = { result -> + dispatchEvent(Event.PurchaseSubmitted(result.swapId, result.mint)) }, onError = { cause -> dispatchEvent(Event.UpdateProcessingState()) @@ -612,8 +588,8 @@ internal class CurrencyCreatorViewModel @Inject constructor( ) } else { BottomBarManager.showError( - title = resources.getString(R.string.error_title_buyNewCurrencyFailed), - message = resources.getString(R.string.error_description_buyNewCurrencyFailed), + title = resources.getString(R.string.error_title_launchTokenFailed), + message = resources.getString(R.string.error_description_launchTokenFailed), ) } } @@ -641,60 +617,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( dispatchEvent(Event.UpdateProcessingState()) } ).launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) - if (addMoney) { - if (!purchaseMethodController.state.value.hasReserves) { - // Creating a currency is funded from USDF reserves; require a deposit first. - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = resources.getString(R.string.description_noBalanceYetToCreate), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_addMoney) - ) { - dispatchEvent(Event.PresentDepositOptions) - }, - ), - showCancel = true, - ) - return@onEach - } else if (purchaseMethodController.state.value.reservesBalance.nativeAmount <= stateFlow.value.totalCost) { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_insufficientBalance), - message = resources.getString(R.string.description_insufficientBalanceToCreate), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_addMoreMoney) - ) { - dispatchEvent(Event.PresentDepositOptions) - }, - ), - showCancel = true, - ) - return@onEach - } - } - - dispatchEvent(Event.AdvanceFromInfo) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { - if (!featureFlags.get(FeatureFlag.AddMoneyUX)) { - return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) - } - // popToRoot = false so finishing the deposit returns to the currency - // creator (which pushed this flow) rather than tearing down the whole - // sheet and losing the user's place in the flow. - purchaseMethodController.presentDepositOptions(popToRoot = false) - } - .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } - .launchIn(viewModelScope) } /** @@ -712,42 +634,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun handlePhantomError(error: Throwable) { - val deeplinkError = error as? DeeplinkOnRampError - ?: DeeplinkOnRampError.FailedToCreateTransaction(message = error.message, cause = error) - - if (deeplinkError is DeeplinkOnRampError.WalletProvidedError && deeplinkError.code == DeeplinkError.UserRejectedRequest.code) { - // user cancelled — not an error worth reporting - } else { - trace( - tag = "onramp::deeplinks", - message = "Phantom error in currency creator", - type = TraceType.Error, - error = deeplinkError.takeUnless { it.isAlert } - ) - } - - val providerName = resources.getString(com.flipcash.shared.onramp.deeplinks.R.string.label_phantom) - val (title, message) = deeplinkError.messaging(resources::getString, providerName) - - when { - deeplinkError.isNetworkCause -> { - BottomBarManager.showAlert( - title = resources.getString(com.flipcash.shared.onramp.deeplinks.R.string.error_title_noInternet), - message = resources.getString(com.flipcash.shared.onramp.deeplinks.R.string.error_description_noInternet), - ) - } - deeplinkError.isAlert -> { - BottomBarManager.showAlert(title = title, message = message) - } - else -> { - BottomBarManager.showError(title = title, message = message) - } - } - - dispatchEvent(Event.UpdateProcessingState()) - } - internal companion object { val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { @@ -795,6 +681,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( is Event.OnTokenMinted -> { state -> state.copy(createdMint = event.mint) } is Event.Purchase -> { state -> state } + is Event.ConfirmPurchase -> { state -> state } is Event.CheckName -> { state -> state } is Event.CheckDescription -> { state -> state } is Event.CheckImage -> { state -> state } @@ -813,13 +700,10 @@ internal class CurrencyCreatorViewModel @Inject constructor( state.copy(attestations = attestations.copy(icon = event.attestation)) } - is Event.PurchaseWithReserves -> { state -> state } Event.OnIntroContinue -> { state -> state } Event.AdvanceFromInfo -> { state -> state } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } - is Event.PurchaseWithPhantom -> { state -> state } - is Event.PurchaseWithGooglePay -> { state -> state } is Event.PurchaseSubmitted -> { state -> state } is Event.PurchaseCompleted -> { state -> state.copy(launchedToken = event.token) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/FundingSourceSelectionScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/FundingSourceSelectionScreen.kt new file mode 100644 index 000000000..bd4eaa7f3 --- /dev/null +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/FundingSourceSelectionScreen.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.currencycreator.internal.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.tokens.CurrencyCreatorResult +import com.flipcash.app.core.tokens.CurrencyCreatorStep +import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.currencycreator.internal.CurrencyCreatorViewModel +import com.flipcash.app.tokens.TokenSelectScreen +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun FundingSourceSelectionScreen() { + val selectionViewModel = hiltViewModel() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + + // The currency-creator flow provides its own top bar for this step, so suppress the + // select screen's own bar to avoid stacking two. + TokenSelectScreen(TokenPurpose.LaunchFunding(state.totalCost), showTopBar = false) + + LaunchedEffect(selectionViewModel) { + selectionViewModel.eventFlow + .filterIsInstance() + .filter { it.fromUser } + .map { it.mint } + .onEach { viewModel.dispatchEvent(CurrencyCreatorViewModel.Event.ConfirmPurchase(fundedWith = it)) } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.swapId } + .onEach { + flowNavigator.navigateTo(CurrencyCreatorStep.Processing) + }.launchIn(this) + } +} diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewAndPurchaseScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt similarity index 95% rename from apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewAndPurchaseScreen.kt rename to apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt index 0ecacd493..88e9c1f5a 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewAndPurchaseScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt @@ -36,24 +36,23 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable -internal fun ReviewAndPurchaseScreen() { +internal fun BillReviewScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - ReviewAndPurchaseContent(state, viewModel::dispatchEvent) + ReviewContent(state, viewModel::dispatchEvent) LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() - .map { it.swapId } + .filterIsInstance() .onEach { - flowNavigator.navigateTo(CurrencyCreatorStep.Processing) + flowNavigator.navigateTo(CurrencyCreatorStep.FundingSourceSelection) }.launchIn(this) } } @Composable -internal fun ReviewAndPurchaseContent( +internal fun ReviewContent( state: CurrencyCreatorViewModel.State, dispatch: (CurrencyCreatorViewModel.Event) -> Unit ) { 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 1df3e4834..925af7cdc 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 @@ -2,9 +2,7 @@ package com.flipcash.app.tokens import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -28,7 +26,6 @@ import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry -import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -116,7 +113,7 @@ private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) { val viewModel = flowSharedViewModel() val flowNavigator = rememberFlowNavigator() - TokenSelectScreen(TokenPurpose.Purchase(targetMint, amount)) + TokenSelectScreen(TokenPurpose.Swap(targetMint, amount)) LaunchedEffect(selectionViewModel) { selectionViewModel.eventFlow diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt index 388253f18..95094f67d 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute import com.flipcash.app.core.AppRoute.Transfers.Deposit import com.flipcash.app.core.AppRoute.Transfers.Withdrawal import com.flipcash.app.core.tokens.TokenPurpose @@ -24,7 +23,10 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable -fun TokenSelectScreen(purpose: TokenPurpose) { +fun TokenSelectScreen( + purpose: TokenPurpose, + showTopBar: Boolean = purpose !is TokenPurpose.LaunchFunding, +) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() @@ -32,16 +34,18 @@ fun TokenSelectScreen(purpose: TokenPurpose) { modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithTitle( - title = when (purpose) { - is TokenPurpose.Purchase -> stringResource(R.string.title_selectPaymentCurrency) - else -> stringResource(R.string.title_selectCurrency) - }, - backButton = true, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - + if (showTopBar) { + AppBarWithTitle( + title = when (purpose) { + is TokenPurpose.Swap -> stringResource(R.string.title_selectPaymentCurrency) + is TokenPurpose.LaunchFunding -> stringResource(R.string.title_selectPaymentCurrency) + else -> stringResource(R.string.title_selectCurrency) + }, + backButton = true, + onBackIconClicked = { navigator.pop() }, + titleAlignment = Alignment.CenterHorizontally, + ) + } SelectTokenScreen(viewModel) } @@ -67,7 +71,7 @@ fun TokenSelectScreen(purpose: TokenPurpose) { .onEach { when (purpose) { TokenPurpose.Balance -> Unit - TokenPurpose.Select -> Unit + is TokenPurpose.Select -> Unit TokenPurpose.Withdraw -> { navigator.push(Withdrawal()) } @@ -76,7 +80,8 @@ fun TokenSelectScreen(purpose: TokenPurpose) { navigator.push(Deposit()) } - is TokenPurpose.Purchase -> Unit + is TokenPurpose.LaunchFunding -> Unit + is TokenPurpose.Swap -> Unit } }.launchIn(this) } 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 abcb9f4cc..6f97bab26 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 @@ -15,17 +15,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenBalanceStyle import com.flipcash.app.core.ui.TokenSelectionStyle import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling -import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.tokens.ui.TokenList import com.flipcash.features.tokens.R import com.getcode.theme.CodeTheme -import com.getcode.utils.trace @Composable internal fun SelectTokenScreen( @@ -49,13 +49,28 @@ private fun SelectTokenScreenContent( selectedToken = state.selectedToken, styling = rememberTokenBalanceRowStyling( balanceDisplayStyle = TokenBalanceStyle.Pill(), - selectionStyle = if (state.purpose is TokenPurpose.Select) TokenSelectionStyle.Checkbox else TokenSelectionStyle.Chevron, + selectionStyle = when (state.purpose) { + TokenPurpose.Balance -> TokenSelectionStyle.Chevron + TokenPurpose.Deposit -> TokenSelectionStyle.Chevron + is TokenPurpose.Swap -> TokenSelectionStyle.Chevron + is TokenPurpose.LaunchFunding -> TokenSelectionStyle.Chevron + is TokenPurpose.Select -> TokenSelectionStyle.Checkbox + TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron + } ), showSelections = state.purpose is TokenPurpose.Select, - showFlags = state.purpose !is TokenPurpose.Select, + showFlags = when (state.purpose) { + is TokenPurpose.Select -> false + is TokenPurpose.Swap -> false + is TokenPurpose.LaunchFunding -> false + else -> true + }, enableGreaterThanAmount = { _, amount -> when (val purpose = state.purpose) { - is TokenPurpose.Purchase -> { + is TokenPurpose.Swap -> { + amount.nativeAmount.valueGreaterThanOrEqualTo(purpose.amount) + } + is TokenPurpose.LaunchFunding -> { amount.nativeAmount.valueGreaterThanOrEqualTo(purpose.amount) } else -> true @@ -98,15 +113,12 @@ private fun SelectTokenScreenContent( @Composable @Preview +@PreviewWrapper(FlipcashThemeWrapper::class) private fun PreviewEmptyState() { - FlipcashPreview(showBackground = true) { - SelectTokenScreenContent( - state = SelectTokenViewModel.State( - purpose = TokenPurpose.Select, - tokens = emptyList(), - ), - ) { - - } - } + SelectTokenScreenContent( + state = SelectTokenViewModel.State( + purpose = TokenPurpose.Select, + tokens = emptyList(), + ), + ) {} } \ No newline at end of file diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index af0ee1f4a..5ec509805 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -1,16 +1,11 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.internal.model.thirdparty.OnRampType import com.flipcash.shared.tokens.R import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Fiat @@ -31,7 +26,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -109,11 +103,15 @@ class SelectTokenViewModel @Inject constructor( tokenCoordinator.tokenBalances, exchange.observePreferredRate() ) { balances, rate -> + val rateForPurpose = when (purpose) { + is TokenPurpose.LaunchFunding -> exchange.rateForUsd() + else -> rate + } balances .map { val balance = LocalFiat( usdf = it.balance, - nativeAmount = it.balance.convertingTo(rate), + nativeAmount = it.balance.convertingTo(rateForPurpose), ) // USD reserves don't appreciate so we track that as MIN_VALUE internally to avoid confusion @@ -121,12 +119,12 @@ class SelectTokenViewModel @Inject constructor( val appreciation = if (it.appreciation == Fiat.MIN_VALUE) { LocalFiat( usdf = 0.toFiat(), - nativeAmount = 0.toFiat(rate.currency), + nativeAmount = 0.toFiat(rateForPurpose.currency), ) } else { LocalFiat( usdf = it.appreciation, - nativeAmount = it.appreciation.convertingTo(rate), + nativeAmount = it.appreciation.convertingTo(rateForPurpose), ) } @@ -136,23 +134,10 @@ class SelectTokenViewModel @Inject constructor( appreciation = appreciation, displayName = when (purpose) { TokenPurpose.Balance -> it.token.name - TokenPurpose.Deposit -> { - if (it.token.address == Mint.usdf) { - resources.getString(R.string.displayName_usdf) - } else { - it.token.name - } - } - - is TokenPurpose.Purchase -> { - if (it.token.address == Mint.usdf) { - resources.getString(R.string.displayName_usdf) - } else { - it.token.name - } - } - TokenPurpose.Select -> it.token.name + is TokenPurpose.Swap, + is TokenPurpose.LaunchFunding, + TokenPurpose.Deposit, TokenPurpose.Withdraw -> { if (it.token.address == Mint.usdf) { resources.getString(R.string.displayName_usdf) @@ -160,6 +145,9 @@ class SelectTokenViewModel @Inject constructor( it.token.name } } + + is TokenPurpose.Select -> it.token.name + } ) } @@ -169,10 +157,11 @@ class SelectTokenViewModel @Inject constructor( ) .filter { val hasBalance = it.balance.nativeAmount.hasDisplayableValue - when (val details = purpose) { + when (purpose) { // show all tokens we have accounts for as deposit targets TokenPurpose.Deposit -> true - TokenPurpose.Select -> { + + is TokenPurpose.Select -> { if (it.token.address == Mint.usdf) { stateFlow.value.canGiveUsdf && hasBalance } else { @@ -180,8 +169,12 @@ class SelectTokenViewModel @Inject constructor( } } - is TokenPurpose.Purchase -> { - if (it.token.address != details.desiredToken) { + is TokenPurpose.LaunchFunding -> { + hasBalance + } + + is TokenPurpose.Swap -> { + if (it.token.address != purpose.desiredToken) { hasBalance } else { false 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 d5942d6ef..f4a6029fe 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 @@ -56,12 +56,12 @@ import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.TokenWithLocalizedBalance -import com.getcode.opencode.model.financial.div import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee import com.getcode.opencode.model.financial.launchpadSellFee +import com.getcode.opencode.model.financial.max +import com.getcode.opencode.model.financial.min import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.plus -import com.getcode.opencode.model.financial.times import com.getcode.opencode.model.financial.toFiat import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.SwapState @@ -76,11 +76,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -91,6 +93,8 @@ import kotlinx.coroutines.launch import java.util.concurrent.CancellationException import javax.inject.Inject import kotlin.collections.listOf +import kotlin.collections.map +import kotlin.collections.orEmpty data class AmountEntryState( val limits: Limits? = null, @@ -161,15 +165,18 @@ class SwapViewModel @Inject constructor( stateFlow.map { it.purpose }, stateFlow.map { it.amountEntryState.maxToAdd }, stateFlow.map { it.tokenBalance }, - stateFlow.map { it.reservesBalance }, - ) { purpose, maxToAdd, tokenBalance, reservesBalance -> + tokenCoordinator.tokenBalances.distinctUntilChanged(), + ) { purpose, maxToAdd, tokenBalance, tokenBalances -> when (purpose) { is SwapPurpose.Buy -> { + val limit = maxToAdd?.let { Fiat(it.first, it.second) } val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) if (mustAddMoney && !stateFlow.value.isAddingMoney) { - return@combine reservesBalance + val maxTokenBalance = tokenBalances.maxOf { it.balance } + return@combine limit?.let { min(maxTokenBalance, it) } ?: maxTokenBalance } - maxToAdd?.let { Fiat(it.first, it.second) } + + limit } is SwapPurpose.Sell -> tokenBalance null -> null @@ -426,22 +433,25 @@ class SwapViewModel @Inject constructor( ) } - private val transactionLimit: Fiat - get() = when (stateFlow.value.purpose) { + private suspend fun transactionLimit(): Fiat { + return when (stateFlow.value.purpose) { is SwapPurpose.Buy -> { val mustAddMoney = featureFlags.observe(FeatureFlag.AddMoneyUX).value - if (mustAddMoney && !stateFlow.value.isAddingMoney) { - return stateFlow.value.reservesBalance - } - val sendLimit = enteredAmount.currencyCode.let { stateFlow.value.amountEntryState.limits?.sendLimitFor(it) } ?: SendLimit.Zero - sendLimit.maxPerDay.toFiat(enteredAmount.currencyCode) + val maxSendPerDay = sendLimit.maxPerDay.toFiat(enteredAmount.currencyCode) + if (mustAddMoney && !stateFlow.value.isAddingMoney) { + val balances = tokenCoordinator.tokenBalances.firstOrNull().orEmpty().map { it.balance } + min((balances.maxOrNull() ?: Fiat.Zero), maxSendPerDay) + } else { + maxSendPerDay + } } is SwapPurpose.Sell -> stateFlow.value.tokenBalance null -> Fiat.Zero } + } val checkBalanceLimit: () -> Boolean = { val amount = amountDelegate.state.value.enteredAmount @@ -482,7 +492,7 @@ class SwapViewModel @Inject constructor( } val checkFundingAmount: suspend () -> Boolean = { - val limit = transactionLimit + val limit = transactionLimit() val isOverLimit = enteredAmount.valueGreaterThan(limit) val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) val isAddingMoney = stateFlow.value.isAddingMoney @@ -972,7 +982,7 @@ class SwapViewModel @Inject constructor( } else { // `amount` is grossed up; the pool's sell fee is applied on-chain during the // swap, so feeAmount is left null (the server rejects a non-zero fee here). - transactionController.crossCurrencySwap( + transactionController.swap( owner = owner, amount = amount, from = fundingToken.token, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt index 41b41038f..18059380a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt @@ -40,6 +40,7 @@ object ControllerFactory { fun createCurrencyController(context: Context, config: ProtocolConfig): CurrencyController { return CurrencyController( repository = RepositoryFactory.createCurrencyRepository(context, config), + transactionController = createTransactionController(context, config), ) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/CurrencyController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/CurrencyController.kt index 6a9e25d28..c7fb9fc71 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/CurrencyController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/CurrencyController.kt @@ -1,7 +1,9 @@ package com.getcode.opencode.controllers -import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.errors.CheckTokenAvailabilityError +import com.getcode.opencode.model.core.errors.LaunchTokenError import com.getcode.opencode.model.financial.LiveMintDataResponse import com.getcode.opencode.model.ui.WindowedRange import com.getcode.opencode.model.financial.CurrencyCode @@ -9,9 +11,8 @@ import com.getcode.opencode.model.financial.HistoricalMintData import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenCreateRequest -import com.getcode.opencode.model.moderation.ModerationAttestation +import com.getcode.opencode.model.financial.fromLaunch import com.getcode.opencode.model.ui.DiscoverCategory -import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.opencode.repositories.CurrencyRepository import com.getcode.solana.keys.Mint import kotlinx.coroutines.CoroutineScope @@ -35,6 +36,7 @@ import javax.inject.Singleton @Singleton class CurrencyController @Inject constructor( private val repository: CurrencyRepository, + private val transactionController: TransactionController, ) { /** * Returns a long-lived [Flow] of [LiveMintDataResponse] events for the @@ -133,10 +135,53 @@ class CurrencyController @Inject constructor( } } + /** + * Registers the new currency's metadata on-chain and funds it in one operation, returning the + * created [Mint] and the funding [SwapId]. + * + * Funding is driven by [TokenCreateRequest.funding] and routed by [TransactionController.launchToken]: + * USDF funds via a plain buy of the freshly-launched stub, any other currency via the treasury + * swap that creates and funds the new currency in a single transaction. + */ suspend fun launchToken( request: TokenCreateRequest, - owner: Ed25519.KeyPair, - ): Result { - return repository.launchToken(request, owner) + owner: AccountCluster, + existingMint: Mint? = null, + onMinted: (Mint) -> Unit = {}, + ): Result { + val funding = request.funding + return repository.launchToken(request, owner.authority.keyPair) + .recoverCatching { cause -> + // The server returns Exists when the mint is already registered. If a prior attempt + // this session minted but its funding failed, reuse that mint so funding can retry. + if (cause is LaunchTokenError.Exists && existingMint != null) existingMint + else throw cause + } + .mapCatching { mint -> + // Surface the mint before funding so the caller can track it for a retry. + onMinted(mint) + val stub = MintMetadata.fromLaunch( + mint = mint, + request = request, + owner = owner.authorityPublicKey, + ) + + val swapId = transactionController.launchToken( + owner = owner, + amount = funding.amount, + fullAmount = funding.fullAmount, + feeAmount = funding.feeAmount, + fundingSource = funding.token, + newToken = stub, + ).getOrThrow() + + LaunchResult(mint = mint, swapId = swapId) + } } -} \ No newline at end of file +} + +/** The result of launching and funding a new currency. */ +data class LaunchResult( + val mint: Mint, + val swapId: SwapId, +) \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index 7124693fc..cb8f2f5a6 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -2,11 +2,13 @@ package com.getcode.opencode.controllers import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.valuedAt import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.network.api.intents.IntentDistribution import com.getcode.opencode.internal.network.api.intents.IntentRemoteReceive import com.getcode.opencode.internal.network.api.intents.IntentRemoteSend import com.getcode.opencode.internal.network.api.intents.IntentTransfer +import com.getcode.opencode.internal.extensions.exchangeDataFor import com.getcode.opencode.internal.solana.extensions.newInstance import com.getcode.opencode.internal.network.api.intents.IntentWithdraw import com.getcode.opencode.internal.solana.model.SwapId @@ -404,7 +406,80 @@ class TransactionController @Inject constructor( } } - override suspend fun crossCurrencySwap( + override suspend fun swap( + owner: AccountCluster, + amount: VerifiedFiat, + feeAmount: LocalFiat?, + from: Token, + to: Token, + swapId: SwapId?, + source: SwapFundingSource, + fund: (suspend (StatefulSwapRequest) -> Result)?, + ): Result = doCrossCurrencySwap( + owner = owner, + amount = amount, + feeAmount = feeAmount, + from = from, + to = to, + fullAmountExchangeData = null, + swapId = swapId, + source = source, + fund = fund, + ) + + /** + * Funds a brand-new currency launch of the freshly-launched stub [newToken] using [fundingSource]. USDF + * triggers a plain [buy] of [newToken] under the hood; any other currency triggers the treasury-funded + * swap that creates and funds the new currency in a single transaction, deriving the required + * full-amount exchange data from [amount]'s verified state. + */ + suspend fun launchToken( + owner: AccountCluster, + amount: VerifiedFiat, + fullAmount: Fiat, + feeAmount: LocalFiat? = null, + fundingSource: Token, + newToken: Token, + swapId: SwapId? = null, + source: SwapFundingSource = SwapFundingSource.SubmitIntent(), + fund: (suspend (StatefulSwapRequest) -> Result)? = null, + ): Result { + if (fundingSource.address == Mint.usdf) { + // USDF-backed launch is just a buy of the freshly-launched stub. + return buy( + owner = owner, + amount = amount, + feeAmount = feeAmount, + swapId = swapId, + of = newToken, + source = source, + fund = fund, + ) + } + + // The treasury flow is checked against the exact USD launch cost, but compute() reports a + // bonding-curve sell estimate as the native amount — pin it to [fullAmount], keeping the + // funding-token quarks. The pinned amount feeds both the full-amount exchange data and the + // swap (whose funding intent is validated against the same USD value). + val funded = amount.valuedAt(fullAmount) + val fullAmountExchangeData = funded.verifiedState?.exchangeDataFor( + amount = funded.localFiat, + mint = fundingSource.address, + ) + return doCrossCurrencySwap( + owner = owner, + amount = funded, + feeAmount = feeAmount, + from = fundingSource, + to = newToken, + fullAmountExchangeData = fullAmountExchangeData, + swapId = swapId, + source = source, + fund = fund, + ) + } + + private suspend fun doCrossCurrencySwap( owner: AccountCluster, amount: VerifiedFiat, feeAmount: LocalFiat?, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt index 13de27c73..29942b6a7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt @@ -7,7 +7,6 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token -import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.SwapMetadata import com.getcode.opencode.model.transactions.StatefulSwapRequest @@ -46,18 +45,17 @@ interface TransactionOperations { ): Result /** - * Swaps one launchpad currency directly for another (neither side the core mint). When [to] is - * a freshly-launched stub the server creates it during the swap using a treasury-funded flow, - * which requires [fullAmountExchangeData] — a USD valuation over the full swap + fee amount keyed - * to [from]. For existing destinations [fullAmountExchangeData] is not needed. + * Swaps one launchpad currency directly for another existing currency (neither side the core + * mint). To fund a brand-new currency launch by swapping from another currency, use + * [TransactionController.launchToken] — that path is treasury-funded and creates the + * destination during the swap. */ - suspend fun crossCurrencySwap( + suspend fun swap( owner: AccountCluster, amount: VerifiedFiat, feeAmount: LocalFiat? = null, from: Token, to: Token, - fullAmountExchangeData: ExchangeData.Verified? = null, swapId: SwapId? = null, source: SwapFundingSource = SwapFundingSource.SubmitIntent(), fund: (suspend (StatefulSwapRequest) -> Result)? = null, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt index ffd9f4c04..39d46804f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt @@ -22,6 +22,17 @@ data class VerifiedFiat( val verifiedState: VerifiedState? = null, ) : Parcelable +/** + * Returns this amount with its [LocalFiat.nativeAmount] pinned to [value], keeping the on-chain + * [LocalFiat.underlyingTokenAmount] (the funding-token quarks) intact. + * + * [compute] reports a bonding-curve sell estimate as the native amount. Some server flows — the + * treasury launch swap and its funding intent — instead require the exact input value (a fixed USD + * cost), so pin it here without disturbing the token quarks the swap actually moves. + */ +fun VerifiedFiat.valuedAt(value: Fiat): VerifiedFiat = + copy(localFiat = localFiat.copy(nativeAmount = value)) + interface VerifiedFiatCalculator { suspend fun resolveVerifiedState( currencyCode: CurrencyCode, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/extensions/VerifiedState.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/extensions/VerifiedState.kt index 8fd61d0b3..b7a10504f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/extensions/VerifiedState.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/extensions/VerifiedState.kt @@ -14,7 +14,7 @@ private val DefaultBillExchangeDataTimeout = 15.minutes fun VerifiedState.exchangeDataFor( amount: LocalFiat, mint: Mint, - billExchangeDataTimeout: Duration? + billExchangeDataTimeout: Duration? = null ): ExchangeData.Verified? { val timeout = billExchangeDataTimeout ?: DefaultBillExchangeDataTimeout if (timeout <= Duration.ZERO) return null diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt index ff95d5b2f..d239fa4b1 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt @@ -117,7 +117,7 @@ fun MintMetadata.Companion.fromLaunch( address = mint, decimals = 10, name = request.name.text, - symbol = request.symbol?.text ?: "", + symbol = request.symbol?.text ?: request.name.text.take(6).uppercase(), description = request.description?.text.orEmpty(), createdAt = Clock.System.now(), imageUrl = "", diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/TokenCreateRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/TokenCreateRequest.kt index fc78eee69..da51a296f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/TokenCreateRequest.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/TokenCreateRequest.kt @@ -1,5 +1,6 @@ package com.getcode.opencode.model.financial +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.model.moderation.ModerationAttestation import com.getcode.opencode.model.ui.TokenBillCustomizations @@ -9,4 +10,21 @@ data class TokenCreateRequest( val description: ModerationAttestation.Text?, val bill: TokenBillCustomizations?, val icon: ModerationAttestation.Image?, -) + val funding: Funding, +) { + /** + * How the launch is paid for. [token] is the funding currency — USDF (reserves) or any + * launchpad currency; the service layer decides buy vs. cross-currency swap from it. + * + * [amount] is the funding-token valuation whose on-chain quarks are the amount of [token] to + * move (for a launchpad currency, token quarks from the bonding curve), plus the verified state. + * [fullAmount] is the exact USD launch cost — the service pins [amount]'s native value to it for + * the treasury flow. [feeAmount] is the pool fee in funding-token terms. + */ + data class Funding( + val token: Token, + val amount: VerifiedFiat, + val fullAmount: Fiat, + val feeAmount: LocalFiat? = null, + ) +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/services/CurrencyServiceTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/services/CurrencyServiceTest.kt index 0d285819e..471001af3 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/services/CurrencyServiceTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/network/services/CurrencyServiceTest.kt @@ -250,6 +250,12 @@ class CurrencyServiceTest { description = null, bill = null, icon = null, + // Funding is consumed by the controller, not the launchToken API mapping under test here. + funding = TokenCreateRequest.Funding( + token = mockk(relaxed = true), + amount = mockk(relaxed = true), + fullAmount = mockk(relaxed = true), + ), ) private fun makeIconRequest() = TokenUpdateRequest.Icon(