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(