diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt
index 01e639775..877bba58d 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt
@@ -15,6 +15,14 @@ sealed interface SwapStep : FlowStep, Parcelable {
@Serializable
data class Entry(val purpose: SwapPurpose, val initialAmount: Fiat? = null) : SwapStep
+ @Parcelize
+ @Serializable
+ data class TokenSelection(val amount: Fiat) : SwapStep
+
+ @Parcelize
+ @Serializable
+ data object BuyReceipt : SwapStep
+
@Parcelize
@Serializable
data object SellReceipt : SwapStep
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 28852acd1..9fd43361e 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
@@ -1,6 +1,9 @@
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
@@ -8,6 +11,7 @@ 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 object Withdraw: TokenPurpose
@Serializable data object Deposit: TokenPurpose
@Serializable data object Balance : TokenPurpose
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt
index 737e3c55d..16b7ab876 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
@@ -56,6 +57,7 @@ data class TokenBalanceRowStyling(
val iconSize: Dp,
val flagSize: Dp,
val selectionStyle: TokenSelectionStyle,
+ val disabledAlpha: Float,
)
@Composable
@@ -65,13 +67,15 @@ fun rememberTokenBalanceRowStyling(
iconSize: Dp = CodeTheme.dimens.staticGrid.x6,
flagSize: Dp = CodeTheme.dimens.staticGrid.x3,
selectionStyle: TokenSelectionStyle = TokenSelectionStyle.None,
+ disabledAlpha: Float = 0.8f,
): TokenBalanceRowStyling =
TokenBalanceRowStyling(
nameTextStyle = nameTextStyle,
balanceDisplayStyle = balanceDisplayStyle,
iconSize = iconSize,
flagSize = flagSize,
- selectionStyle = selectionStyle
+ selectionStyle = selectionStyle,
+ disabledAlpha = disabledAlpha
)
@Composable
@@ -82,6 +86,7 @@ fun TokenBalanceRow(
showFlag: Boolean = false,
showLogo: Boolean = true,
isSelected: Boolean? = null,
+ isEnabled: Boolean = true,
iconOverride: @Composable ((Any?) -> Any?) = { it },
formattedBalance: (Fiat) -> String = { it.formatted() },
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
@@ -97,6 +102,7 @@ fun TokenBalanceRow(
iconOverride = iconOverride,
formattedBalance = formattedBalance,
isSelected = isSelected,
+ isEnabled = isEnabled,
modifier = modifier,
showName = showName,
showFlag = showFlag,
@@ -116,6 +122,7 @@ fun TokenBalanceRow(
showLogo: Boolean = true,
showFlag: Boolean = false,
isSelected: Boolean? = null,
+ isEnabled: Boolean = true,
iconOverride: @Composable ((Any?) -> Any?) = { it },
formattedBalance: (Fiat) -> String = { it.formatted() },
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
@@ -132,6 +139,7 @@ fun TokenBalanceRow(
showLogo = showLogo,
showFlag = showFlag,
isSelected = isSelected,
+ isEnabled = isEnabled,
modifier = modifier,
styling = styling,
iconOverride = iconOverride,
@@ -153,6 +161,7 @@ fun TokenBalanceRow(
showLogo: Boolean = true,
showFlag: Boolean = false,
isSelected: Boolean? = null,
+ isEnabled: Boolean = true,
iconOverride: @Composable ((Any?) -> Any?) = { it },
formattedBalance: (Fiat) -> String = { it.formatted() },
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
@@ -161,14 +170,21 @@ fun TokenBalanceRow(
onClick: (() -> Unit)? = null,
) {
val exchange = LocalExchange.current
+ val backgroundColor = CodeTheme.colors.background
Row(
modifier = Modifier
- .addIf(onClick != null) {
+ .addIf(onClick != null && isEnabled) {
Modifier.clickable {
onClick?.invoke()
}
}
.then(modifier)
+ .addIf(!isEnabled) {
+ Modifier.drawWithContent {
+ drawContent()
+ drawRect(color = backgroundColor, alpha = styling.disabledAlpha)
+ }
+ }
.padding(contentPadding),
horizontalArrangement = horizontalArrangement,
verticalAlignment = Alignment.CenterVertically,
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 1d971b1cb..f4bdc8ed4 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -430,6 +430,7 @@
Add Solana USDF to your Flipcash wallet
Select Currency
+ Select Payment Currency
Select Region
Recent Regions
Other Regions
@@ -441,8 +442,13 @@
Something Went Wrong
Failed to fetch token metadata. Please try again
+ You Pay
You Receive
+ Insufficient Balance After Fees
+ Switch to maximum amount, or go back and enter a smaller amount
+ Buy Maximum Amount
+
Give
Send
Transaction History
@@ -529,6 +535,7 @@
Sell
Amount to Withdraw
Solana USDC with
+ Buy
Sell %1$s
Purchasing %1$s
Adding Money
@@ -836,7 +843,9 @@
Unknown Contact
Allow Full Contact Access
- >Make sure you can send cash and identify people you know
- Allow Access">
+ Make sure you can send cash and identify people you know
+ Allow Access
+ Amount to buy
+ Exchange fee
\ No newline at end of file
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt
index fc12fd267..9058ba646 100644
--- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt
@@ -75,6 +75,14 @@ internal fun SwapEntryScreen(
}
}
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .map { it.amount }
+ .onEach { flowNavigator.navigateTo(SwapStep.TokenSelection(it)) }
+ .launchIn(this)
+ }
+
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance()
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 7b1bdff52..1df3e4834 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
@@ -1,6 +1,10 @@
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
@@ -10,6 +14,9 @@ import com.flipcash.app.core.tokens.FundingSource
import com.flipcash.app.core.tokens.SwapPurpose
import com.flipcash.app.core.tokens.SwapResult
import com.flipcash.app.core.tokens.SwapStep
+import com.flipcash.app.core.tokens.TokenPurpose
+import com.flipcash.app.tokens.ui.SelectTokenViewModel
+import com.flipcash.app.tokens.ui.SwapViewModel
import com.getcode.opencode.model.financial.Fiat
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
@@ -17,8 +24,17 @@ import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.flow.FlowExitReason
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.flow.deliverFlowResult
+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
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
@Composable
fun SwapFlowScreen(
@@ -76,6 +92,12 @@ private fun swapEntryProvider(
annotatedEntry { step ->
SwapEntryScreen(step.purpose, step.initialAmount)
}
+ annotatedEntry { key ->
+ SwapPurchaseTokenSelectScreen(route.purpose.mint, key.amount)
+ }
+ annotatedEntry {
+ BuyReceiptScreen()
+ }
annotatedEntry { SellReceiptScreen() }
annotatedEntry {
PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose)
@@ -87,3 +109,24 @@ private fun swapEntryProvider(
SwapProcessingScreen()
}
}
+
+@Composable
+private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) {
+ val selectionViewModel = hiltViewModel()
+ val viewModel = flowSharedViewModel()
+ val flowNavigator = rememberFlowNavigator()
+
+ TokenSelectScreen(TokenPurpose.Purchase(targetMint, amount))
+
+ LaunchedEffect(selectionViewModel) {
+ selectionViewModel.eventFlow
+ .filterIsInstance()
+ .filter { it.fromUser }
+ .map { it.mint }
+ .onEach {
+ viewModel.dispatchEvent(SwapViewModel.Event.OnFundingTokenSelected(it))
+ flowNavigator.navigateTo(SwapStep.BuyReceipt)
+ }
+ .launchIn(this)
+ }
+}
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt
new file mode 100644
index 000000000..966f54546
--- /dev/null
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt
@@ -0,0 +1,48 @@
+package com.flipcash.app.tokens
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.flipcash.app.core.tokens.SwapResult
+import com.flipcash.app.core.tokens.SwapStep
+import com.flipcash.app.tokens.internal.TokenBuyReceiptScreen
+import com.flipcash.app.tokens.ui.SwapViewModel
+import com.flipcash.features.tokens.R
+import com.getcode.navigation.flow.flowSharedViewModel
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.ui.components.AppBarWithTitle
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@Composable
+internal fun BuyReceiptScreen() {
+ val flowNavigator = rememberFlowNavigator()
+ val viewModel = flowSharedViewModel()
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_buyToken),
+ titleAlignment = Alignment.CenterHorizontally,
+ backButton = true,
+ onBackIconClicked = { flowNavigator.back() }
+ )
+
+ TokenBuyReceiptScreen(viewModel)
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach {
+ flowNavigator.navigateTo(SwapStep.Processing)
+ }.launchIn(this)
+ }
+}
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 00b80431e..388253f18 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
@@ -26,57 +26,66 @@ import kotlinx.coroutines.flow.onEach
@Composable
fun TokenSelectScreen(purpose: TokenPurpose) {
val navigator = LocalCodeNavigator.current
+ val viewModel = hiltViewModel()
+
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithTitle(
- title = stringResource(R.string.title_selectCurrency),
+ 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,
)
- val viewModel = hiltViewModel()
+
+
SelectTokenScreen(viewModel)
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(purpose))
+ }
- LaunchedEffect(viewModel) {
- viewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(purpose))
- }
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .map { it.route }
+ .onEach { navigator.push(it) }
+ .launchIn(this)
+ }
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
- .filterIsInstance()
- .map { it.route }
- .onEach { navigator.push(it) }
- .launchIn(this)
- }
+ // handle the cases where we are inserted in a flow to select a token
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .filter { it.fromUser }
+ .map { it.mint }
+ .onEach {
+ when (purpose) {
+ TokenPurpose.Balance -> Unit
+ TokenPurpose.Select -> Unit
+ TokenPurpose.Withdraw -> {
+ navigator.push(Withdrawal())
+ }
- // handle the cases where we are inserted in a flow to select a token
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
- .filterIsInstance()
- .filter { it.fromUser }
- .map { it.mint }
- .onEach { token ->
- when (purpose) {
- TokenPurpose.Balance -> Unit
- TokenPurpose.Select -> Unit
- TokenPurpose.Withdraw -> {
- navigator.push(Withdrawal())
- }
- TokenPurpose.Deposit -> {
- navigator.push(Deposit())
- }
+ TokenPurpose.Deposit -> {
+ navigator.push(Deposit())
}
- }.launchIn(this)
- }
- // handle the case where we are changing the selected token
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
- .filterIsInstance()
- .onEach { navigator.pop() }
- .launchIn(this)
- }
+ is TokenPurpose.Purchase -> Unit
+ }
+ }.launchIn(this)
+ }
+
+ // handle the case where we are changing the selected token
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach { navigator.pop() }
+ .launchIn(this)
}
}
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt
new file mode 100644
index 000000000..38e668aba
--- /dev/null
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt
@@ -0,0 +1,217 @@
+package com.flipcash.app.tokens.internal
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.flipcash.app.core.ui.ReceiptLineItem
+import com.flipcash.app.core.ui.TokenBalanceRow
+import com.flipcash.app.core.ui.TokenBalanceStyle
+import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling
+import com.flipcash.app.tokens.ui.SwapViewModel
+import com.flipcash.features.tokens.R
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Token
+import com.getcode.opencode.model.financial.TokenWithBalance
+import com.getcode.opencode.model.financial.plus
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White05
+import com.getcode.theme.bolded
+import com.getcode.ui.theme.ButtonState
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeScaffold
+
+@Composable
+internal fun TokenBuyReceiptScreen(viewModel: SwapViewModel) {
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ TokenBuyReceiptScreen(state, viewModel::dispatchEvent)
+}
+
+@Composable
+private fun TokenBuyReceiptScreen(
+ state: SwapViewModel.State,
+ dispatchEvent: (SwapViewModel.Event) -> Unit,
+) {
+ CodeScaffold(
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.grid.x5),
+ text = stringResource(R.string.label_sellWarning),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .imePadding()
+ .padding(bottom = CodeTheme.dimens.grid.x3),
+ text = stringResource(R.string.action_buy),
+ buttonState = ButtonState.Filled,
+ isLoading = state.buyProgress.loading,
+ isSuccess = state.buyProgress.success,
+ ) {
+ dispatchEvent(SwapViewModel.Event.OnBuyConfirmed)
+ }
+ }
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = CodeTheme.dimens.grid.x8),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(
+ CodeTheme.dimens.inset,
+ alignment = Alignment.CenterVertically
+ )
+ ) {
+ BuyReceipt(
+ fundingToken = state.fundingTokenWithBalance!!.token,
+ desiredToken = state.tokenWithBalance!!.token,
+ purchaseAmount = state.confirmedEnteredAmount!!,
+ feeAmount = state.feeAmount,
+ )
+ }
+ }
+}
+
+@Composable
+private fun BuyReceipt(
+ fundingToken: Token,
+ desiredToken: Token,
+ purchaseAmount: Fiat,
+ feeAmount: Fiat,
+ modifier: Modifier = Modifier,
+) {
+ val feeAdjustedPurchaseAmount by remember(purchaseAmount, feeAmount) {
+ derivedStateOf {
+ if (!feeAmount.hasDisplayableValue) return@derivedStateOf purchaseAmount
+ purchaseAmount + feeAmount
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ width = CodeTheme.dimens.border,
+ color = CodeTheme.colors.border,
+ shape = CodeTheme.shapes.medium
+ )
+ .background(White05, CodeTheme.shapes.medium)
+ .padding(
+ horizontal = CodeTheme.dimens.grid.x4,
+ vertical = CodeTheme.dimens.inset
+ )
+ .then(modifier),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.subtitle_youPay),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ TokenBalanceRow(
+ tokenWithBalance = TokenWithBalance(
+ token = fundingToken,
+ balance = feeAdjustedPurchaseAmount
+ ),
+ showName = false,
+ showLogo = true,
+ showFlag = false,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ styling = rememberTokenBalanceRowStyling(
+ balanceDisplayStyle = TokenBalanceStyle.Large(
+ textStyle = CodeTheme.typography.displaySmall.bolded()
+ ),
+ ),
+ contentPadding = PaddingValues(0.dp),
+ )
+ }
+
+ if (feeAmount.isPositive) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ ReceiptLineItem(
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(R.string.label_amountToBuy),
+ amount = purchaseAmount.formatted()
+ )
+ ReceiptLineItem(
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(R.string.label_exchangeFee),
+ amount = feeAmount.formatted(
+ extraPrefix = if (feeAmount.decimalValue < 0.01) "~" else null,
+ )
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.subtitle_youReceive),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ TokenBalanceRow(
+ tokenWithBalance = TokenWithBalance(
+ token = desiredToken,
+ balance = purchaseAmount
+ ),
+ showName = false,
+ showLogo = true,
+ showFlag = false,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ styling = rememberTokenBalanceRowStyling(
+ balanceDisplayStyle = TokenBalanceStyle.Large(
+ textStyle = CodeTheme.typography.displaySmall.bolded()
+ ),
+ ),
+ contentPadding = PaddingValues(0.dp),
+ )
+ }
+ }
+}
\ No newline at end of file
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 6aff413f5..abcb9f4cc 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
@@ -25,6 +25,7 @@ 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(
@@ -52,6 +53,14 @@ private fun SelectTokenScreenContent(
),
showSelections = state.purpose is TokenPurpose.Select,
showFlags = state.purpose !is TokenPurpose.Select,
+ enableGreaterThanAmount = { _, amount ->
+ when (val purpose = state.purpose) {
+ is TokenPurpose.Purchase -> {
+ amount.nativeAmount.valueGreaterThanOrEqualTo(purpose.amount)
+ }
+ else -> true
+ }
+ },
emptyState = {
Box(
modifier = Modifier
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSellReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSellReceiptScreen.kt
index 9edff7496..503638d3e 100644
--- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSellReceiptScreen.kt
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSellReceiptScreen.kt
@@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Text
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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 2ce7ea16c..af0ee1f4a 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
@@ -144,6 +144,14 @@ class SelectTokenViewModel @Inject constructor(
}
}
+ is TokenPurpose.Purchase -> {
+ if (it.token.address == Mint.usdf) {
+ resources.getString(R.string.displayName_usdf)
+ } else {
+ it.token.name
+ }
+ }
+
TokenPurpose.Select -> it.token.name
TokenPurpose.Withdraw -> {
if (it.token.address == Mint.usdf) {
@@ -161,7 +169,7 @@ class SelectTokenViewModel @Inject constructor(
)
.filter {
val hasBalance = it.balance.nativeAmount.hasDisplayableValue
- when (purpose) {
+ when (val details = purpose) {
// show all tokens we have accounts for as deposit targets
TokenPurpose.Deposit -> true
TokenPurpose.Select -> {
@@ -171,6 +179,14 @@ class SelectTokenViewModel @Inject constructor(
hasBalance
}
}
+
+ is TokenPurpose.Purchase -> {
+ if (it.token.address != details.desiredToken) {
+ hasBalance
+ } else {
+ false
+ }
+ }
// show all tokens with non-zero balance
else -> hasBalance
}
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 733ef64e1..d5942d6ef 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,6 +56,11 @@ 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.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
@@ -134,7 +139,7 @@ class SwapViewModel @Inject constructor(
}
isBuy -> {
- AmountEntryLabel.Plain(resources.getString(R.string.action_buy))
+ AmountEntryLabel.Plain(resources.getString(R.string.action_next))
}
else -> {
@@ -207,6 +212,7 @@ class SwapViewModel @Inject constructor(
val confirmedFeeAmount: Fiat? = null,
val minimumBuyAmount: Fiat? = null,
val pendingInitialAmount: Fiat? = null,
+ val fundingTokenWithBalance: TokenWithBalance? = null,
) {
val sellFee: Double?
get() {
@@ -260,6 +266,11 @@ class SwapViewModel @Inject constructor(
data object ConfirmPhantomTransaction : Event
data object OnAmountConfirmed : Event
+ data class SelectFundingToken(val amount: Fiat): Event
+ data class OnFundingTokenSelected(val mint: Mint): Event
+ data class OnFundingTokenResolved(val token: TokenWithBalance): Event
+
+ data object OnBuyConfirmed: Event
data object OnSellConfirmed : Event
data class UpdateBuyState(
@@ -345,8 +356,9 @@ class SwapViewModel @Inject constructor(
private val feeAmount: Fiat
get() {
- val fee = stateFlow.value.sellFee ?: return Fiat.Zero
- return enteredAmount * (fee / 100.0)
+ val bps = stateFlow.value.tokenWithBalance?.token
+ ?.launchpadMetadata?.sellFeeBps ?: return Fiat.Zero
+ return enteredAmount.launchpadSellFee(bps)
}
private val netTransferAmount: Fiat
@@ -358,6 +370,62 @@ class SwapViewModel @Inject constructor(
)
}
+ /**
+ * Cross-currency buys pay the funding pool's sell fee on top of the entered amount, so
+ * "You Pay" = amount + fee. If that total exceeds the funding token's wallet balance, the user
+ * can't cover it — surface a modal (automatically, on landing the receipt) offering to drop to
+ * the maximum affordable amount rather than letting the buy fail.
+ *
+ * No-op for USDF funding (no fee), and sub-cent rounding is tolerated so applying the max
+ * doesn't immediately re-prompt. All comparisons are in USD ([Fiat.convertingToUsdIfNeeded]),
+ * the common denominator across native currencies.
+ */
+ private fun maybePromptInsufficientBalanceAfterFees(
+ fundingToken: Token,
+ payTotal: Fiat,
+ rate: Rate,
+ ) {
+ if (fundingToken.address == Mint.usdf) return
+
+ val balanceUsd = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate)
+ val payUsd = payTotal.convertingToUsdIfNeeded(rate)
+ if ((payUsd - balanceUsd) <= balanceUsd.smallestUnit) return
+
+ BottomBarManager.showInfo(
+ title = resources.getString(R.string.title_insufficientBalanceAfterFees),
+ message = resources.getString(R.string.description_insufficientBalanceAfterFees),
+ actions = listOf(
+ BottomBarAction(
+ text = resources.getString(R.string.action_buyMaximumAmount),
+ style = BottomBarManager.BottomBarButtonStyle.Filled,
+ ) {
+ // The most we can pay is the full balance, so the most we can receive (net of
+ // the fee) is balance - fee(balance). This only corrects the displayed receipt
+ // (via OnAmountAccepted, a pure state update): the buy already caps the pay amount
+ // to the balance on-chain, and the entered amount is left untouched so returning
+ // to amount entry preserves what the user typed. selectedAmount is passed through
+ // unchanged.
+ val balanceNative = balanceUsd.convertingTo(rate)
+ val maxReceive = balanceNative -
+ balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0)
+ val maxFee = balanceNative - maxReceive
+ dispatchEvent(
+ Event.OnAmountAccepted(
+ amount = stateFlow.value.amountEntryState.selectedAmount,
+ netTransferAmount = maxReceive,
+ enteredAmount = maxReceive,
+ feeAmount = maxFee,
+ )
+ )
+ },
+ BottomBarAction(
+ text = resources.getString(R.string.action_dismiss),
+ style = BottomBarManager.BottomBarButtonStyle.Text,
+ ),
+ ),
+ )
+ }
+
private val transactionLimit: Fiat
get() = when (stateFlow.value.purpose) {
is SwapPurpose.Buy -> {
@@ -582,13 +650,6 @@ class SwapViewModel @Inject constructor(
when (purpose) {
is SwapPurpose.Buy -> {
val rate = exchange.preferredRate
- val currencyCode = delegateState.currency.code ?: CurrencyCode.USD
- val conversionRate = exchange.rateToUsd(currencyCode) ?: Rate.ignore
- val enteredInUsdf = Fiat(
- delegateState.enteredAmount,
- currencyCode,
- ).convertingTo(conversionRate)
- val reservesBalance = stateFlow.value.reservesBalance
when {
purpose.fundingSource == FundingSource.Phantom -> {
@@ -598,36 +659,19 @@ class SwapViewModel @Inject constructor(
dispatchEvent(Event.ConfirmPhantomTransaction)
}
- !isAddingMoney && enteredInUsdf <= reservesBalance.rounded() -> {
- // Sufficient USDF reserves — buy the token directly from reserves
- val amountFiat = verifiedFiatCalculator.compute(
- amount = Fiat(delegateState.enteredAmount, rate.currency),
- token = Token.usdf,
- balance = reservesBalance.convertingToUsdIfNeeded(rate),
- rate = rate
- ).getOrElse {
- BottomBarManager.showAlert(
- title = resources.getString(R.string.error_title_staleRates),
- message = resources.getString(R.string.error_description_staleRates),
- )
- return@onEach
- }
- val netAmount = amountFiat.localFiat.nativeAmount
-
- dispatchEvent(Event.UpdateBuyState(loading = true))
+ !isAddingMoney -> {
+ // Direct buy — let the user choose which token funds it.
+ // The reserves-vs-cross-currency decision is deferred to
+ // the buyOrSwap flow, once a funding token is selected.
dispatchEvent(
- Event.OnAmountAccepted(
- amountFiat,
- netTransferAmount = netAmount,
- enteredAmount = enteredAmount,
- feeAmount = feeAmount,
+ Event.SelectFundingToken(
+ Fiat(delegateState.enteredAmount, rate.currency)
)
)
- dispatchEvent(Event.ProceedWithPurchase(amountFiat))
}
else -> {
- // Insufficient reserves — check available purchase methods
+ // Adding money via an external source — check purchase methods
val mint = purpose.mint
val metadata = PurchaseMethodMetadata(
mint = mint,
@@ -795,30 +839,156 @@ class SwapViewModel @Inject constructor(
dispatchEvent(Event.ProceedWithSale(it))
}.launchIn(viewModelScope)
+ eventFlow
+ .filterIsInstance()
+ .filter { stateFlow.value.purpose is SwapPurpose.Buy }
+ .map { it.mint }
+ .mapNotNull {
+ val token = tokenCoordinator.getTokenMetadata(it).getOrNull()?.token ?: return@mapNotNull null
+ val delegateState = amountDelegate.state.value
+ val rate = exchange.preferredRate
+ val amountFiat = verifiedFiatCalculator.compute(
+ amount = Fiat(delegateState.enteredAmount, rate.currency),
+ token = token,
+ rate = rate,
+ ).getOrElse {
+ BottomBarManager.showAlert(
+ title = resources.getString(R.string.error_title_staleRates),
+ message = resources.getString(R.string.error_description_staleRates),
+ )
+ return@mapNotNull null
+ }
+
+ val nativeAmount = amountFiat.localFiat.nativeAmount
+
+ val tokenWithBalance = TokenWithBalance(
+ token = token,
+ balance = nativeAmount
+ )
+
+ val exchangeFee = if (token.address == Mint.usdf) {
+ 0.toFiat((rate.currency))
+ } else {
+ // The pool's sell fee is grossed up on top of the entered amount, so the
+ // fee is (amount / (1 - fee)) - amount. Uses the funding pool's own bps,
+ // matching the gross-up applied at buy time in OnBuyConfirmed.
+ nativeAmount.grossingUpLaunchpadSellFee(
+ token.launchpadMetadata?.sellFeeBps ?: 0,
+ ) - nativeAmount
+ }
+
+ dispatchEvent(
+ Event.OnAmountAccepted(
+ amountFiat,
+ // The success screen shows this as "amount received of {token}", so it must
+ // be the purchase amount — what lands in the target token — not the grossed-up
+ // debit. The confirmation's "you pay" total is recomputed from enteredAmount +
+ // feeAmount separately (see BuyReceipt), so it stays correct.
+ netTransferAmount = amountFiat.localFiat.nativeAmount,
+ enteredAmount = enteredAmount,
+ feeAmount = exchangeFee,
+ )
+ )
+ dispatchEvent(Event.OnFundingTokenResolved(tokenWithBalance))
+
+ // Landing the receipt: if the fee pushes "You Pay" past the funding token's
+ // wallet balance, auto-offer to drop to the maximum affordable amount.
+ maybePromptInsufficientBalanceAfterFees(
+ fundingToken = token,
+ payTotal = nativeAmount + exchangeFee,
+ rate = rate,
+ )
+ }.onEach {
+
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach { event ->
+ if (stateFlow.value.purpose !is SwapPurpose.Buy) return@onEach
+ // Guard: the executor force-unwraps the target token.
+ stateFlow.value.tokenWithBalance?.token ?: return@onEach
+ val rate = exchange.preferredRate
+
+ val fundingToken = stateFlow.value.fundingTokenWithBalance?.token ?: return@onEach
+
+ // The amount delegate persists across the token-select screen, so the
+ // entered amount can be recomputed here rather than threaded through state.
+ val enteredFiat = Fiat(amountDelegate.state.value.enteredAmount, rate.currency)
+ // Non-USDF funding pays the pool's sell fee, applied implicitly on-chain during
+ // the swap. Gross up the entered (net) amount by 1/(1 - fee) so the on-chain fee
+ // deduction nets back down to exactly what was entered. The fee is the FUNDING
+ // pool's — that's the token being sold — and is sent as zero (server-enforced) in
+ // the swap request below. USDF pays no pool fee.
+ val amountFiat = verifiedFiatCalculator.compute(
+ amount = if (fundingToken.address == Mint.usdf) enteredFiat
+ else enteredFiat.grossingUpLaunchpadSellFee(
+ bps = fundingToken.launchpadMetadata?.sellFeeBps ?: 0,
+ ),
+ token = fundingToken,
+ balance = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate),
+ rate = rate,
+ ).getOrElse {
+ BottomBarManager.showAlert(
+ title = resources.getString(R.string.error_title_staleRates),
+ message = resources.getString(R.string.error_description_staleRates),
+ )
+ return@onEach
+ }
+
+ dispatchEvent(Event.ProceedWithPurchase(amountFiat))
+ }.launchIn(viewModelScope)
+
eventFlow
.filterIsInstance()
.onEach { dispatchEvent(Event.UpdateBuyState(loading = true)) }
- .map { it.amount }
- .mapNotNull { amount ->
+ .mapNotNull { event ->
val owner = userManager.accountCluster ?: return@mapNotNull null
- val purpose = stateFlow.value.purpose ?: return@mapNotNull null
- owner to purpose to amount
+ stateFlow.value.purpose ?: return@mapNotNull null
+ val targetToken = stateFlow.value.tokenWithBalance?.token ?: return@mapNotNull null
+ Triple(owner, targetToken, event)
}
- .map { (owner, purpose, amount) -> owner to stateFlow.value.tokenWithBalance!!.token to amount }
- .onEach { (owner, token, amount) ->
- transactionController.buy(
- owner = owner,
- amount = amount,
- of = token,
- ).onSuccess { swapId ->
- trackTransaction(token)
+ .onEach { (owner, targetToken, event) ->
+ val amount = event.amount
+ // The funding token was chosen on the token-select screen; USDF means
+ // buy straight from reserves, anything else is a cross-currency swap.
+ val fundingToken = stateFlow.value.fundingTokenWithBalance
+
+ if (fundingToken == null) {
+ dispatchEvent(Event.UpdateBuyState(loading = false, success = false))
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_buySellFailed),
+ message = resources.getString(R.string.error_description_buySellFailed),
+ )
+ return@onEach
+ }
+
+ val result = if (fundingToken.token.address == Mint.usdf) {
+ transactionController.buy(
+ owner = owner,
+ amount = amount,
+ of = targetToken,
+ )
+ } 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(
+ owner = owner,
+ amount = amount,
+ from = fundingToken.token,
+ to = targetToken,
+ )
+ }
+
+ result.onSuccess { swapId ->
+ trackTransaction(targetToken)
dispatchEvent(Event.OnSwapIdChanged(swapId))
- dispatchEvent(Event.OnPurchaseSubmitted(token, swapId))
+ dispatchEvent(Event.OnPurchaseSubmitted(targetToken, swapId))
dispatchEvent(Event.UpdateBuyState(loading = false, success = true))
- // buy submitted from reserves, drop reserves balance
- tokenCoordinator.subtract(Token.usdf, amount.localFiat)
+ // buy/swap submitted, drop the spent balance from the funding token
+ tokenCoordinator.subtract(fundingToken.token, amount.localFiat)
}.onFailure { cause ->
- trackTransaction(token, error = cause)
+ trackTransaction(targetToken, error = cause)
dispatchEvent(Event.UpdateBuyState(loading = false, success = false))
if (cause is SwapError.InvalidSwap) {
if (cause.insufficientBalance) {
@@ -1362,8 +1532,13 @@ class SwapViewModel @Inject constructor(
is Event.PhantomNavigateToProcessing,
Event.PhantomCeremonyFailed,
is Event.DepositSubmitted,
+ Event.OnBuyConfirmed,
Event.OnAmountConfirmed -> { state -> state }
+ is Event.SelectFundingToken -> { state -> state }
+ is Event.OnFundingTokenSelected -> { state -> state }
+ is Event.OnFundingTokenResolved -> { state -> state.copy(fundingTokenWithBalance = event.token) }
+
is Event.UpdateBuyState -> { state ->
val entryState = state.buyProgress
state.copy(
diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt
index c6f6a1039..08c180eec 100644
--- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt
+++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -28,6 +29,7 @@ import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.TokenWithLocalizedBalance
+import com.getcode.opencode.model.financial.compareTo
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.base58
import com.getcode.theme.CodeTheme
@@ -49,6 +51,7 @@ fun TokenList(
showSelections: Boolean = false,
includeReserves: Boolean = true,
pinFooter: Boolean = false,
+ enableGreaterThanAmount: (mint: Mint, LocalFiat) -> Boolean = { _, _ -> true },
emptyState: (@Composable LazyItemScope.() -> Unit)? = null,
reserves: (@Composable LazyItemScope.(mint: Mint, cashReserves: LocalFiat) -> Unit)? = null,
header: (@Composable () -> Unit)? = null,
@@ -69,6 +72,7 @@ fun TokenList(
derivedStateOf { !pinFooter || !listState.canScrollForward || listState.isScrolledToEnd() }
}
+
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier
@@ -95,6 +99,7 @@ fun TokenList(
items = filteredTokens.orEmpty(),
key = { item -> item.token.address.base58() },
contentType = { "token_row" }) { item ->
+ val updatedIsEnabled by rememberUpdatedState(enableGreaterThanAmount(item.token.address, item.balance))
TokenBalanceRow(
modifier = Modifier
.fillParentMaxWidth()
@@ -104,6 +109,7 @@ fun TokenList(
showFlag = showFlags,
styling = styling,
isSelected = (selectedToken == item.token.address).takeIf { showSelections },
+ isEnabled = updatedIsEnabled,
) { onTokenSelected(item.token) }
HorizontalDivider(color = CodeTheme.colors.dividerVariant)
diff --git a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto
index 67d7ac47c..b0851e46a 100644
--- a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto
+++ b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto
@@ -403,7 +403,18 @@ message StatefulSwapRequest {
// The fee amount to pay for this swap
+ //
+ // Note: Amounts are coordinated outside this RPC for user verifications
+ // and validated in this RPC.
uint64 fee_amount = 7;
+
+ // Verified exchange data for flows that require a specific fiat value
+ // over the full amount (swap + fee). For intent fundings, it's expected
+ // that this exchange data will be used.
+ //
+ // Required for the following flows:
+ // - Initializing a new reserve currency outside of the core mint
+ VerifiedExchangeData full_amount_exchange_data = 8;
}
// Client parameters for starting swaps against the Coinbase Stable Swaper program
@@ -530,7 +541,7 @@ message StatefulSwapResponse {
// 4. [Optional] Memo::Memo
// 5. AssociatedTokenAccount::CreateIdempotent (open Core Mint temporary account)
// 6. VM::TransferForSwap (Core Mint VM swap ATA -> Core Mint temporary account)
- // 6. Reserve::BuyAndDepositIntoVm (bounded buy depositing to_mint tokens into the to_mint VM)
+ // 7. Reserve::BuyAndDepositIntoVm (bounded buy depositing to_mint tokens into the to_mint VM)
// 8. Token::CloseAccount (closes Core Mint temporary account)
// 9. VM::CloseSwapAccountIfEmpty (closes Core Mint VM swap ATA if empty)
//
@@ -606,7 +617,7 @@ message StatefulSwapResponse {
//
// Supported Solana transaction version: v0
//
- // Instruction format:
+ // Instruction format (paying with Core Mint):
// 1. System::AdvanceNonce
// 2. [Optional] ComputeBudget::SetComputeUnitLimit
// 3. [Optional] ComputeBudget::SetComputeUnitPrice
@@ -620,6 +631,20 @@ message StatefulSwapResponse {
// 11. Reserve::BuyTokens (limited buy transferring to_mint tokens into the to_mint VM Deposit ATA)
// 12. Token::CloseAccount (closes owner's Core Mint ATA)
//
+ // Instruction format (all other cases):
+ // 1. System::AdvanceNonce
+ // 2. [Optional] ComputeBudget::SetComputeUnitLimit
+ // 3. [Optional] ComputeBudget::SetComputeUnitPrice
+ // 4. [Optional] Memo::Memo
+ // 5. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA)
+ // 6. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee amount))
+ // 7. Reserve::SellTokens (limited sell of the full swap + fee amount, transferring the Core Mint value into the fee destination)
+ // 8. Reserve::BuyTokens (limited buy funded by the treasury's Core Mint ATA, transferring to_mint tokens into the to_mint VM Deposit ATA)
+ //
+ // Note: The currency, VM and to_mint VM Deposit ATA will be initialized prior to
+ // executing this transaction. Atomicity cannot be guaranteed due to transaction
+ // size limits, which will be revisited when the v1 transaction format is released.
+ //
// Note: Client should verify that the new currency's mint address matches that derived
// from using these server parameters.
message ReserveNewCurrencyServerParameter {
@@ -695,6 +720,14 @@ message StatefulSwapResponse {
common.v1.SolanaAccountId fee_destination = 14 [(validate.rules).message.required = true];
+
+ // Server-controlled treasury for flows that require it
+ common.v1.SolanaAccountId treasury = 15;
+
+ // The amount of core mint tokens used for purchase. Client should
+ // validate this is as expected based on a pre-coordinated amount
+ // accepted by the user.
+ uint64 treasury_purchase_amount = 16;
}
// Server parameters when executing stateful swap flows against the
diff --git a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt
index a2ee984ea..75512ccbf 100644
--- a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt
+++ b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt
@@ -88,6 +88,40 @@ class SwapInstructionsTest {
createdAt = Clock.System.now(),
)
+ private val destinationVmMetadata = VmMetadata(
+ authority = generateRandomPublicKeyForTest(),
+ vm = generateRandomPublicKeyForTest(),
+ lockDurationInDays = 21,
+ )
+
+ private val destinationLaunchpadMetadata = LaunchpadMetadata(
+ currencyConfig = generateRandomPublicKeyForTest(),
+ liquidityPool = generateRandomPublicKeyForTest(),
+ seed = generateRandomPublicKeyForTest(),
+ authority = generateRandomPublicKeyForTest(),
+ mintVault = generateRandomPublicKeyForTest(),
+ coreMintVault = generateRandomPublicKeyForTest(),
+ currentCirculatingSupplyQuarks = 0,
+ sellFeeBps = 0,
+ price = Fiat.MIN_VALUE,
+ marketCap = Fiat.MIN_VALUE,
+ )
+
+ private val destinationMint = MintMetadata(
+ address = Mint(PublicKey.generate().bytes),
+ vmMetadata = destinationVmMetadata,
+ launchpadMetadata = destinationLaunchpadMetadata,
+ decimals = 6,
+ name = "Dest Token",
+ symbol = "DTOK",
+ description = "",
+ imageUrl = "",
+ billCustomizations = null,
+ socialLinks = emptyList(),
+ holderMetrics = HolderMetrics.None,
+ createdAt = Clock.System.now(),
+ )
+
// Mock Server Response (Stateless for simplicity)
private val mockServerParams = StatefulSwapResponseServerParameters.ExistingCurrency(
payer = mockPayer,
@@ -351,7 +385,16 @@ class SwapInstructionsTest {
sellFeeBps = 100,
vmLockDurationInDays = 21,
alts = emptyList(),
- feeDestination = mockFeeDestination
+ feeDestination = mockFeeDestination,
+ treasury = null,
+ treasuryPurchaseAmount = 0,
+ )
+
+ private val mockTreasury = generateRandomPublicKeyForTest()
+
+ private val mockTreasuryNewCurrencyServerParams = mockNewCurrencyServerParams.copy(
+ treasury = mockTreasury,
+ treasuryPurchaseAmount = 250_000L,
)
@Test
@@ -537,6 +580,190 @@ class SwapInstructionsTest {
assertEquals(mockNewCurrencyAuthority, instructions[10].accounts[2].publicKey)
}
+ @Test
+ fun testBuildTreasuryFundedNewCurrencyBuyInstructionsCount() {
+ val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
+ serverParameters = mockTreasuryNewCurrencyServerParams,
+ nonce = mockNonce,
+ authority = mockNewCurrencyAuthority,
+ sourceMintMetadata = targetMint,
+ coreMintMetadata = coreMint,
+ swapAmount = 95_000L,
+ feeAmount = 5_000L,
+ )
+
+ // Expected sequence (7 instructions):
+ // 1. System::AdvanceNonce
+ // 2. ComputeBudget::SetComputeUnitLimit
+ // 3. ComputeBudget::SetComputeUnitPrice
+ // 4. AssociatedTokenAccount::CreateIdempotent (treasury from_mint ATA)
+ // 5. VM::TransferForSwapWithFee
+ // 6. Reserve::SellTokens
+ // 7. Reserve::BuyTokens
+ assertEquals("Should generate 7 instructions", 7, instructions.size)
+ }
+
+ @Test
+ fun testBuildTreasuryFundedNewCurrencyBuyInstructionPrograms() {
+ val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
+ serverParameters = mockTreasuryNewCurrencyServerParams,
+ nonce = mockNonce,
+ authority = mockNewCurrencyAuthority,
+ sourceMintMetadata = targetMint,
+ coreMintMetadata = coreMint,
+ swapAmount = 95_000L,
+ feeAmount = 5_000L,
+ )
+
+ assertEquals(SystemProgram.address, instructions[0].program)
+ assertEquals(ComputeBudgetProgram.address, instructions[1].program)
+ assertEquals(ComputeBudgetProgram.address, instructions[2].program)
+ assertEquals(AssociatedTokenProgram.address, instructions[3].program)
+ assertEquals(VirtualMachineProgram.address, instructions[4].program)
+ assertEquals(CurrencyCreatorProgram.address, instructions[5].program)
+ assertEquals(CurrencyCreatorProgram.address, instructions[6].program)
+ }
+
+ @Test
+ fun testBuildTreasuryFundedNewCurrencyBuyInstructionAccounts() {
+ val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
+ serverParameters = mockTreasuryNewCurrencyServerParams,
+ nonce = mockNonce,
+ authority = mockNewCurrencyAuthority,
+ sourceMintMetadata = targetMint,
+ coreMintMetadata = coreMint,
+ swapAmount = 95_000L,
+ feeAmount = 5_000L,
+ )
+
+ val treasuryFromMintAta = PublicKey.deriveAssociatedAccount(
+ mockTreasury, targetMint.address
+ ).publicKey
+ val treasuryCoreMintAta = PublicKey.deriveAssociatedAccount(
+ mockTreasury, coreMint.address
+ ).publicKey
+
+ // 4. CreateIdempotent for the treasury's from_mint ATA: owner=treasury(1), mint=from(3)
+ assertEquals(mockTreasury, instructions[3].accounts[1].publicKey)
+ assertEquals(targetMint.address, instructions[3].accounts[3].publicKey)
+
+ // 5. TransferForSwapWithFee: source VM authority(0)/vm(1); both destination(5) and
+ // feeDestination(6) route the full swap + fee into the treasury's from_mint ATA
+ assertEquals(targetMint.vmMetadata.authority, instructions[4].accounts[0].publicKey)
+ assertEquals(targetMint.vmMetadata.vm, instructions[4].accounts[1].publicKey)
+ assertEquals(treasuryFromMintAta, instructions[4].accounts[5].publicKey)
+ assertEquals(treasuryFromMintAta, instructions[4].accounts[6].publicKey)
+
+ // 6. SellTokens: the treasury is the seller/signer; core mint proceeds route to the fee
+ // destination (seller=0, sellerTarget=6, sellerBase=7)
+ assertEquals(mockTreasury, instructions[5].accounts[0].publicKey)
+ assertTrue(instructions[5].accounts[0].isSigner)
+ assertEquals(treasuryFromMintAta, instructions[5].accounts[6].publicKey)
+ assertEquals(mockFeeDestination, instructions[5].accounts[7].publicKey)
+
+ // 7. BuyTokens: the treasury funds the buy from its own core mint ATA (buyer=0, buyerBase=7)
+ assertEquals(mockTreasury, instructions[6].accounts[0].publicKey)
+ assertTrue(instructions[6].accounts[0].isSigner)
+ assertEquals(treasuryCoreMintAta, instructions[6].accounts[7].publicKey)
+ }
+
+ @Test
+ fun testBuildCrossCurrencyExistingSwapInstructionsCount() {
+ val instructions = buildCrossCurrencyExistingSwapInstructions(
+ serverParameters = mockServerParams,
+ nonce = mockNonce,
+ authority = mockAuthority,
+ swapAuthority = mockSwapAuthority,
+ fromMintMetadata = targetMint,
+ toMintMetadata = destinationMint,
+ coreMintMetadata = coreMint,
+ amount = 100_000L,
+ )
+
+ // Expected sequence (12 instructions):
+ // 1. System::AdvanceNonce
+ // 2. ComputeBudget::SetComputeUnitLimit
+ // 3. ComputeBudget::SetComputeUnitPrice
+ // 4. Memo::Memo
+ // 5. AssociatedTokenAccount::CreateIdempotent (temp core mint ATA)
+ // 6. AssociatedTokenAccount::CreateIdempotent (temp source mint ATA)
+ // 7. VM::TransferForSwap
+ // 8. Reserve::SellTokens
+ // 9. Reserve::BuyAndDepositIntoVm
+ // 10. Token::CloseAccount (temp core)
+ // 11. Token::CloseAccount (temp source)
+ // 12. VM::CloseSwapAccountIfEmpty
+ assertEquals("Should generate 12 instructions", 12, instructions.size)
+ }
+
+ @Test
+ fun testBuildCrossCurrencyExistingSwapInstructionPrograms() {
+ val instructions = buildCrossCurrencyExistingSwapInstructions(
+ serverParameters = mockServerParams,
+ nonce = mockNonce,
+ authority = mockAuthority,
+ swapAuthority = mockSwapAuthority,
+ fromMintMetadata = targetMint,
+ toMintMetadata = destinationMint,
+ coreMintMetadata = coreMint,
+ amount = 100_000L,
+ )
+
+ assertEquals(SystemProgram.address, instructions[0].program)
+ assertEquals(ComputeBudgetProgram.address, instructions[1].program)
+ assertEquals(ComputeBudgetProgram.address, instructions[2].program)
+ assertEquals(MemoProgram.address, instructions[3].program)
+ assertEquals(AssociatedTokenProgram.address, instructions[4].program)
+ assertEquals(AssociatedTokenProgram.address, instructions[5].program)
+ assertEquals(VirtualMachineProgram.address, instructions[6].program)
+ assertEquals(CurrencyCreatorProgram.address, instructions[7].program)
+ assertEquals(CurrencyCreatorProgram.address, instructions[8].program)
+ assertEquals(TokenProgram.address, instructions[9].program)
+ assertEquals(TokenProgram.address, instructions[10].program)
+ assertEquals(VirtualMachineProgram.address, instructions[11].program)
+ }
+
+ @Test
+ fun testBuildCrossCurrencyExistingSwapInstructionAccounts() {
+ val instructions = buildCrossCurrencyExistingSwapInstructions(
+ serverParameters = mockServerParams,
+ nonce = mockNonce,
+ authority = mockAuthority,
+ swapAuthority = mockSwapAuthority,
+ fromMintMetadata = targetMint,
+ toMintMetadata = destinationMint,
+ coreMintMetadata = coreMint,
+ amount = 100_000L,
+ )
+
+ val tempCoreMintAta = PublicKey.deriveAssociatedAccount(
+ mockSwapAuthority, coreMint.address
+ ).publicKey
+ val tempSourceMintAta = PublicKey.deriveAssociatedAccount(
+ mockSwapAuthority, targetMint.address
+ ).publicKey
+
+ // 7. TransferForSwap: source VM authority(0)/vm(1); swapper(2)=owner; destination(5)=temp source ATA
+ assertEquals(targetMint.vmMetadata.authority, instructions[6].accounts[0].publicKey)
+ assertEquals(targetMint.vmMetadata.vm, instructions[6].accounts[1].publicKey)
+ assertEquals(mockAuthority, instructions[6].accounts[2].publicKey)
+ assertEquals(tempSourceMintAta, instructions[6].accounts[5].publicKey)
+
+ // 8. SellTokens: the swap authority is the seller/signer; core proceeds land in the temp core ATA
+ // (seller=0, sellerTarget=6, sellerBase=7)
+ assertEquals(mockSwapAuthority, instructions[7].accounts[0].publicKey)
+ assertTrue(instructions[7].accounts[0].isSigner)
+ assertEquals(targetMint.launchpadMetadata!!.liquidityPool, instructions[7].accounts[1].publicKey)
+ assertEquals(tempSourceMintAta, instructions[7].accounts[6].publicKey)
+ assertEquals(tempCoreMintAta, instructions[7].accounts[7].publicKey)
+
+ // 9. BuyAndDepositIntoVm: buyer(0)=swap authority, buyerBase(6)=temp core ATA (no buyerTarget,
+ // since it deposits straight into the destination VM); vtaOwner is the owner
+ assertEquals(mockSwapAuthority, instructions[8].accounts[0].publicKey)
+ assertEquals(destinationMint.launchpadMetadata!!.liquidityPool, instructions[8].accounts[1].publicKey)
+ assertEquals(tempCoreMintAta, instructions[8].accounts[6].publicKey)
+ }
+
@Test
fun testBuildNewCurrencyBuyInstructionInitializeCurrencyData() {
val totalAmount = 100_000L
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 291154fa1..7124693fc 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
@@ -404,6 +404,63 @@ class TransactionController @Inject constructor(
}
}
+ override suspend fun crossCurrencySwap(
+ owner: AccountCluster,
+ amount: VerifiedFiat,
+ feeAmount: LocalFiat?,
+ from: Token,
+ to: Token,
+ fullAmountExchangeData: ExchangeData.Verified?,
+ swapId: SwapId?,
+ source: SwapFundingSource,
+ fund: (suspend (StatefulSwapRequest) -> Result)?,
+ ): Result {
+ trace("Starting cross-currency swap of ${from.symbol} -> ${to.symbol}")
+
+ // A freshly-launched stub destination is created by the swap itself, so no destination
+ // account needs provisioning; existing destinations need a VM account to receive the buy.
+ val initializesDestination = to.launchpadMetadata == null && to.address != Mint.usdf
+ val tokenizedOwner = owner.withTimelockForToken(to)
+ val accountResult = when {
+ initializesDestination -> Result.success(Unit)
+ accountController.hasAccountFor(to.address) -> Result.success(Unit)
+ else -> accountController.createUserAccount(
+ ownerForMint = tokenizedOwner,
+ mint = to.address,
+ )
+ }
+
+ val verifiedState = amount.verifiedState
+ ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found")))
+
+ return accountResult.fold(
+ onSuccess = {
+ repository.crossCurrencySwap(
+ scope = scope,
+ owner = owner,
+ amount = amount.localFiat,
+ feeAmount = feeAmount,
+ from = from,
+ to = to,
+ verifiedState = verifiedState,
+ fullAmountExchangeData = fullAmountExchangeData,
+ swapId = swapId,
+ source = source,
+ fund = fund,
+ ).onSuccess { refreshAccountState() }
+ },
+ onFailure = { error ->
+ trace(
+ tag = "TransactionController",
+ message = error.message.orEmpty(),
+ type = TraceType.Error,
+ error = error
+ )
+ Result.failure(error)
+ }
+ )
+ }
+
override suspend fun pollSwapForState(
swapId: SwapId,
owner: AccountCluster,
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 2fb1eed8f..13de27c73 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,6 +7,7 @@ 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
@@ -44,6 +45,24 @@ interface TransactionOperations {
of: Token,
): 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.
+ */
+ suspend fun crossCurrencySwap(
+ 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,
+ ): Result
+
suspend fun pollSwapForState(
swapId: SwapId,
owner: AccountCluster,
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt
index aead054cf..5a689cc61 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt
@@ -8,6 +8,7 @@ import com.getcode.opencode.model.accounts.AccountCluster
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.StatelessSwapRequest
import com.getcode.opencode.model.transactions.SwapFundingSource
import com.getcode.opencode.model.transactions.StatefulSwapRequest
@@ -101,6 +102,32 @@ internal class InternalTransactionRepository @Inject constructor(
verifiedState = verifiedState
).onFailure { ErrorUtils.handleError(it) }
+ override suspend fun crossCurrencySwap(
+ scope: CoroutineScope,
+ owner: AccountCluster,
+ amount: LocalFiat,
+ feeAmount: LocalFiat?,
+ from: Token,
+ to: Token,
+ verifiedState: VerifiedState,
+ fullAmountExchangeData: ExchangeData.Verified?,
+ swapId: SwapId?,
+ source: SwapFundingSource,
+ fund: (suspend (StatefulSwapRequest) -> Result)?,
+ ): Result = service.crossCurrencySwap(
+ scope = scope,
+ amount = amount,
+ feeAmount = feeAmount,
+ from = from,
+ to = to,
+ owner = owner,
+ verifiedState = verifiedState,
+ fullAmountExchangeData = fullAmountExchangeData,
+ swapId = swapId,
+ source = source,
+ fund = fund,
+ ).onFailure { ErrorUtils.handleError(it) }
+
override suspend fun withdrawUsdf(
scope: CoroutineScope,
amount: LocalFiat,
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt
index 99a461e46..1ff05e2dc 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt
@@ -12,7 +12,7 @@ import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.usdf
import com.getcode.opencode.model.transactions.StatefulSwapRequest
import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters
-import com.getcode.opencode.model.transactions.SwapStartKind
+import com.getcode.opencode.model.transactions.SwapProgram
import com.getcode.opencode.model.transactions.VerifiedSwapMetadata
import com.getcode.opencode.solana.SolanaTransaction
import com.getcode.opencode.solana.TransactionBuilder
@@ -47,12 +47,13 @@ internal class IntentStatefulSwap(
response = parameters,
authority = request.owner.authorityPublicKey,
swapAuthority = request.swapAuthority.toPublicKey(),
- direction = request.direction,
+ route = request.route,
amount = request.swapAmount.underlyingTokenAmount.quarks,
)
is StatefulSwapResponseServerParameters.NewCurrency -> TransactionBuilder.buyNewCurrency(
response = parameters,
authority = request.owner.authorityPublicKey,
+ sourceMintMetadata = request.route.sourceMint,
coreMintMetadata = Token.usdf,
amount = request.swapAmount.underlyingTokenAmount.quarks,
feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks,
@@ -62,8 +63,8 @@ internal class IntentStatefulSwap(
response = parameters,
authority = request.owner.authorityPublicKey,
swapAuthority = request.swapAuthority.toPublicKey(),
- destinationOwner = (request.kind as SwapStartKind.Stablecoin).destinationOwner,
- direction = request.direction,
+ destinationOwner = (request.program as SwapProgram.Stablecoin).destinationOwner,
+ route = request.route,
amount = request.swapAmount.underlyingTokenAmount.quarks,
feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks ?: 0,
// server expects 1:1 for stablecoins
@@ -80,9 +81,9 @@ internal class IntentStatefulSwap(
.setOwner(request.owner.authorityPublicKey.asSolanaAccountId())
.setSwapAuthority(request.swapAuthority.toPublicKey().asSolanaAccountId())
.apply {
- when (request.kind) {
- is SwapStartKind.Reserve -> setReserve(request.currencyCreatorParams())
- is SwapStartKind.Stablecoin -> setStablecoin(request.stablecoinParams())
+ when (request.program) {
+ is SwapProgram.Reserve -> setReserve(request.currencyCreatorParams())
+ is SwapProgram.Stablecoin -> setStablecoin(request.stablecoinParams())
}
val proofSignature = request.verifiedMetadata().sign(signer)
setProofSignature(proofSignature)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt
index 6ae20171f..3781ce530 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt
@@ -11,7 +11,7 @@ import com.getcode.opencode.model.core.errors.SubmitIntentError
import com.getcode.opencode.model.core.errors.SwapError
import com.getcode.opencode.model.transactions.StatefulSwapRequest
import com.getcode.opencode.model.transactions.SwapResult
-import com.getcode.opencode.model.transactions.SwapStartKind
+import com.getcode.opencode.model.transactions.SwapProgram
import com.getcode.opencode.model.transactions.VerifiedSwapMetadata
import com.getcode.opencode.solana.SolanaTransaction
import com.getcode.opencode.solana.diff
@@ -60,24 +60,24 @@ internal class SwapExecutor(
streamReference.retain()
- val metadata = when (request.kind) {
- is SwapStartKind.Reserve -> VerifiedSwapMetadata.Reserve(
+ val metadata = when (request.program) {
+ is SwapProgram.Reserve -> VerifiedSwapMetadata.Reserve(
id = request.swapId,
- fromMint = request.direction.sourceMint.address,
- toMint = request.direction.destinationMint.address,
+ fromMint = request.route.sourceMint.address,
+ toMint = request.route.destinationMint.address,
swapAmount = request.swapAmount.underlyingTokenAmount,
feeAmount = request.feeAmount?.underlyingTokenAmount,
- fundingSource = request.kind.fundingSource
+ fundingSource = request.program.fundingSource
)
- is SwapStartKind.Stablecoin -> VerifiedSwapMetadata.StableCoin(
+ is SwapProgram.Stablecoin -> VerifiedSwapMetadata.StableCoin(
id = request.swapId,
- fromMint = request.direction.sourceMint.address,
- toMint = request.direction.destinationMint.address,
+ fromMint = request.route.sourceMint.address,
+ toMint = request.route.destinationMint.address,
swapAmount = request.swapAmount.underlyingTokenAmount,
feeAmount = request.feeAmount?.underlyingTokenAmount,
- fundingSource = request.kind.fundingSource,
- destinationOwner = request.kind.destinationOwner
+ fundingSource = request.program.fundingSource,
+ destinationOwner = request.program.destinationOwner
)
}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
index e02185ad0..186e7ee45 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
@@ -21,7 +21,7 @@ import com.getcode.opencode.model.transactions.GiveRequest
import com.getcode.opencode.model.transactions.GrabRequest
import com.getcode.opencode.model.transactions.SwapFundingSource
import com.getcode.opencode.model.transactions.StatefulSwapRequest
-import com.getcode.opencode.model.transactions.SwapStartKind
+import com.getcode.opencode.model.transactions.SwapProgram
import com.getcode.opencode.model.transactions.TransactionMetadata
import com.getcode.opencode.model.transactions.TransferRequest
import com.getcode.opencode.model.ui.BillBackground
@@ -282,14 +282,22 @@ internal fun LocalFiat.asExchangeData(): OcpTransactionService.ExchangeData {
}
internal fun StatefulSwapRequest.currencyCreatorParams(): OcpTransactionService.StatefulSwapRequest.Initiate.ReserveSwapClientParameters.Builder {
- return when (val details = kind) {
- is SwapStartKind.Reserve -> {
+ return when (val details = program) {
+ is SwapProgram.Reserve -> {
OcpTransactionService.StatefulSwapRequest.Initiate.ReserveSwapClientParameters.newBuilder()
.setId(swapId.asSwapId())
.setFromMint(details.fromMint.asSolanaAccountId())
.setToMint(details.toMint.asSolanaAccountId())
.setSwapAmount(this@currencyCreatorParams.swapAmount.underlyingTokenAmount.quarks)
.setFeeAmount(this@currencyCreatorParams.feeAmount?.underlyingTokenAmount?.quarks ?: 0)
+ .apply {
+ // Required by the server when creating a new reserve currency from a non-core
+ // source mint (treasury-funded flow); omitted otherwise.
+ val fullAmountExchangeData = this@currencyCreatorParams.fullAmountExchangeData
+ if (fullAmountExchangeData != null) {
+ setFullAmountExchangeData(fullAmountExchangeData.asProtobufExchangeData())
+ }
+ }
.apply {
when (val source = details.fundingSource) {
is SwapFundingSource.ExternalWallet -> {
@@ -312,25 +320,25 @@ internal fun StatefulSwapRequest.currencyCreatorParams(): OcpTransactionService.
}
}
- is SwapStartKind.Stablecoin -> {
+ is SwapProgram.Stablecoin -> {
throw IllegalStateException("Stablecoin should not be used for currency creator params")
}
}
}
internal fun StatefulSwapRequest.stablecoinParams(): OcpTransactionService.StatefulSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.Builder {
- return when (val details = kind) {
- is SwapStartKind.Reserve -> {
+ return when (val details = program) {
+ is SwapProgram.Reserve -> {
throw IllegalStateException("Reserve should not be used for stable swapper params")
}
- is SwapStartKind.Stablecoin -> {
+ is SwapProgram.Stablecoin -> {
OcpTransactionService.StatefulSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.newBuilder()
.setId(swapId.asSwapId())
.setFromMint(details.fromMint.asSolanaAccountId())
.setToMint(details.toMint.asSolanaAccountId())
.setSwapAmount(this@stablecoinParams.swapAmount.underlyingTokenAmount.quarks)
- .setDestinationOwner(this@stablecoinParams.kind.destinationOwner.asSolanaAccountId())
+ .setDestinationOwner(this@stablecoinParams.program.destinationOwner.asSolanaAccountId())
.setFeeAmount(this@stablecoinParams.feeAmount?.underlyingTokenAmount?.quarks ?: 0)
.setFundingSource(OcpTransactionService.FundingSource.FUNDING_SOURCE_SUBMIT_INTENT)
.setFundingId(details.fundingSource.id.base58)
@@ -341,12 +349,12 @@ internal fun StatefulSwapRequest.stablecoinParams(): OcpTransactionService.State
internal fun StatefulSwapRequest.verifiedMetadata(): OcpTransactionService.VerifiedSwapMetadata.Builder {
return OcpTransactionService.VerifiedSwapMetadata.newBuilder()
.apply {
- when (kind) {
- is SwapStartKind.Reserve -> setReserve(
+ when (program) {
+ is SwapProgram.Reserve -> setReserve(
OcpTransactionService.VerifiedReserveSwapMetadata.newBuilder()
.setClientParameters(currencyCreatorParams())
)
- is SwapStartKind.Stablecoin -> setStablecoin(
+ is SwapProgram.Stablecoin -> setStablecoin(
OcpTransactionService.VerifiedCoinbaseStableSwapperSwapMetadata.newBuilder()
.setClientParameters(stablecoinParams())
)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt
index 1f25f1a55..f531972b6 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt
@@ -170,6 +170,8 @@ internal fun OcpTransactionService.StatefulSwapResponse.ServerParameters.Reserve
sellFeeBps = sellFeeBps,
vmLockDurationInDays = vmLockDurationInDays,
feeDestination = feeDestination.toPublicKey(),
+ treasury = if (hasTreasury()) treasury.toPublicKey() else null,
+ treasuryPurchaseAmount = treasuryPurchaseAmount,
)
}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt
index 4fb11533e..9a8f70bf5 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt
@@ -24,7 +24,7 @@ internal class SwapFunding @Inject constructor(
intentId = PublicKey(request.fundingIntentId),
sourceCluster = request.owner,
amount = request.totalTransferAmount,
- fromMint = request.direction.sourceMint,
+ fromMint = request.route.sourceMint,
verifiedState = request.verifiedState,
)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt
index 1fe087f37..332e90afc 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt
@@ -23,11 +23,12 @@ 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.financial.minus
+import com.getcode.opencode.model.transactions.ExchangeData
import com.getcode.opencode.model.transactions.StatefulSwapRequest
import com.getcode.opencode.model.transactions.StatelessSwapRequest
-import com.getcode.opencode.model.transactions.SwapDirection
+import com.getcode.opencode.model.transactions.SwapRoute
import com.getcode.opencode.model.transactions.SwapFundingSource
-import com.getcode.opencode.model.transactions.SwapStartKind
+import com.getcode.opencode.model.transactions.SwapProgram
import com.getcode.opencode.model.transactions.TransactionMetadata
import com.getcode.opencode.model.transactions.WithdrawalAvailability
import com.getcode.opencode.solana.intents.IntentType
@@ -196,12 +197,12 @@ internal class TransactionService @Inject constructor(
val request = StatefulSwapRequest(
owner = owner,
swapAuthority = swapAuthority,
- kind = SwapStartKind.Reserve(
+ program = SwapProgram.Reserve(
fromMint = Mint.usdf,
toMint = of.address,
fundingSource = source,
),
- direction = SwapDirection.Buy(of),
+ route = SwapRoute.Buy(of),
swapAmount = netAmount,
feeAmount = feeAmount,
swapId = swapId ?: SwapId.generate(),
@@ -226,12 +227,12 @@ internal class TransactionService @Inject constructor(
owner = tokenCluster,
swapId = SwapId.generate(),
swapAuthority = Ed25519.createKeyPair(),
- kind = SwapStartKind.Reserve(
+ program = SwapProgram.Reserve(
fromMint = of.address,
toMint = Mint.usdf,
fundingSource = source,
),
- direction = SwapDirection.Sell(of),
+ route = SwapRoute.Sell(of),
swapAmount = amount,
feeAmount = null,
verifiedState = verifiedState,
@@ -240,6 +241,56 @@ internal class TransactionService @Inject constructor(
return statefulSwap(scope, request, tokenCluster)
}
+ /**
+ * Swaps one launchpad currency directly for another (neither side the core mint).
+ *
+ * When [to] is an existing currency the server returns RESERVE_EXISTING_CURRENCY parameters and
+ * the swap runs through a one-time [swapAuthority]. When [to] is a freshly-launched stub
+ * (launchpadMetadata == null && address != USDF), the server creates the currency during the
+ * swap and returns RESERVE_NEW_CURRENCY parameters funded by a server-controlled treasury; that
+ * path requires owner == swapAuthority and a [fullAmountExchangeData] valuation over the full
+ * swap + fee amount, keyed to [from] and denominated in USD.
+ */
+ suspend fun crossCurrencySwap(
+ scope: CoroutineScope,
+ amount: LocalFiat,
+ feeAmount: LocalFiat?,
+ from: Token,
+ to: Token,
+ owner: AccountCluster,
+ verifiedState: VerifiedState,
+ fullAmountExchangeData: ExchangeData.Verified? = null,
+ swapId: SwapId? = null,
+ source: SwapFundingSource = SwapFundingSource.SubmitIntent(),
+ fund: (suspend (StatefulSwapRequest) -> Result)? = null,
+ ): Result {
+ val initializesDestination = to.launchpadMetadata == null && to.address != Mint.usdf
+ // New currency requires owner == swapAuthority; existing swaps use a one-time authority.
+ val swapAuthority =
+ if (initializesDestination) owner.authority.keyPair else Ed25519.createKeyPair()
+
+ val netAmount = amount - (feeAmount ?: LocalFiat.Zero)
+ val tokenCluster = owner.withTimelockForToken(from)
+ val request = StatefulSwapRequest(
+ owner = tokenCluster,
+ swapAuthority = swapAuthority,
+ swapId = swapId ?: SwapId.generate(),
+ program = SwapProgram.Reserve(
+ fromMint = from.address,
+ toMint = to.address,
+ fundingSource = source,
+ ),
+ route = SwapRoute.CrossCurrency(from = from, to = to),
+ swapAmount = netAmount,
+ feeAmount = feeAmount,
+ verifiedState = verifiedState,
+ fullAmountExchangeData = fullAmountExchangeData,
+ )
+
+ val fundedWith = fund ?: { swapFunding.fund(scope, tokenCluster, it).map { Unit } }
+ return statefulSwap(scope, request, tokenCluster, fundedWith)
+ }
+
suspend fun withdrawUsdf(
scope: CoroutineScope,
amount: LocalFiat,
@@ -254,13 +305,13 @@ internal class TransactionService @Inject constructor(
owner = owner,
swapId = SwapId.generate(),
swapAuthority = Ed25519.createKeyPair(),
- kind = SwapStartKind.Stablecoin(
+ program = SwapProgram.Stablecoin(
fromMint = Mint.usdf,
toMint = Mint.usdc,
fundingSource = SwapFundingSource.SubmitIntent(),
destinationOwner = destinationOwner
),
- direction = SwapDirection.WithdrawUsdc,
+ route = SwapRoute.WithdrawUsdc,
swapAmount = netAmount,
feeAmount = fee,
verifiedState = verifiedState,
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt
index fee865494..902c80873 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt
@@ -34,6 +34,8 @@ sealed interface ExtractedServerParams {
val sellFeeBps: Int,
val vmLockDurationInDays: Int,
val feeDestination: PublicKey,
+ val treasury: PublicKey?,
+ val treasuryPurchaseAmount: Long,
): ExtractedServerParams
}
@@ -62,6 +64,8 @@ internal fun extractServerParameters(serverParameters: StatefulSwapResponseServe
seed = serverParameters.seed,
sellFeeBps = serverParameters.sellFeeBps,
vmLockDurationInDays = serverParameters.vmLockDurationInDays,
- feeDestination = serverParameters.feeDestination
+ feeDestination = serverParameters.feeDestination,
+ treasury = serverParameters.treasury,
+ treasuryPurchaseAmount = serverParameters.treasuryPurchaseAmount,
)
}
\ No newline at end of file
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt
index a5c123dcf..7b6cde3db 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt
@@ -264,6 +264,10 @@ operator fun Fiat.div(rhs: Int): Fiat {
return Fiat(quarks = this.quarks / rhs, currencyCode = currencyCode)
}
+operator fun Fiat.div(rhs: Double): Fiat {
+ return Fiat(quarks = (this.quarks / rhs).roundToLong(), currencyCode = currencyCode)
+}
+
fun Fiat?.orZero() = this ?: Fiat.Zero
fun Iterable.sum(): Fiat {
return this.fold(Fiat.Zero) { acc, fiat -> acc + fiat }
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt
new file mode 100644
index 000000000..fbc304cf4
--- /dev/null
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt
@@ -0,0 +1,40 @@
+package com.getcode.opencode.model.financial
+
+/**
+ * Launchpad pool sell-fee math, the fiat-domain analog of iOS's
+ * `ExchangedFiat+LaunchpadSellFee`.
+ *
+ * Unlike iOS — which computes the fee in token quarks and needs an
+ * overflow-safe split multiply — Android never multiplies token quarks by bps
+ * on the client. The pool deducts the fee on-chain during the swap; the client
+ * only works in the fiat domain (a [Fiat]'s `Long` micro-unit quarks), where no
+ * realistic amount overflows. Both helpers cap bps at 10_000 (100%) to match
+ * the on-chain clamp.
+ */
+
+private const val MAX_FEE_BPS = 10_000
+
+/**
+ * The launchpad pool's sell fee taken from this amount when it is sold:
+ * `amount × bps / 10_000`.
+ */
+fun Fiat.launchpadSellFee(bps: Int): Fiat {
+ val cappedBps = bps.coerceIn(0, MAX_FEE_BPS)
+ return this * (cappedBps / MAX_FEE_BPS.toDouble())
+}
+
+/**
+ * Grosses this net amount up so that, after the launchpad pool's sell fee is
+ * deducted on-chain, the proceeds net back to this amount:
+ * `net / (1 − bps/10_000)`.
+ *
+ * A 100% (or greater) fee can't be grossed up — the guard returns the amount
+ * unchanged instead of dividing by zero (or going negative). Server-sourced bps
+ * drives this, and the flow gates insufficient funds downstream regardless.
+ */
+fun Fiat.grossingUpLaunchpadSellFee(bps: Int): Fiat {
+ val cappedBps = bps.coerceIn(0, MAX_FEE_BPS)
+ if (cappedBps >= MAX_FEE_BPS) return this
+ val feeFraction = cappedBps / MAX_FEE_BPS.toDouble()
+ return this / (1.0 - feeFraction)
+}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt
index d087ae282..abb86f2be 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt
@@ -148,3 +148,10 @@ operator fun LocalFiat.plus(other: LocalFiat): LocalFiat {
nativeAmount = nativeAmount + other.nativeAmount
)
}
+
+/**
+ * Compares two [LocalFiat] values by their [underlyingTokenAmount], which is always
+ * USD-denominated and therefore the common denominator regardless of native currency.
+ */
+operator fun LocalFiat.compareTo(other: LocalFiat): Int =
+ underlyingTokenAmount.compareTo(other.underlyingTokenAmount)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt
index 9d1474115..f959512ab 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt
@@ -11,17 +11,23 @@ import com.getcode.solana.keys.PublicKey
data class StatefulSwapRequest(
val owner: AccountCluster,
val swapAuthority: KeyPair,
- val kind: SwapStartKind,
- val direction: SwapDirection,
+ val program: SwapProgram,
+ val route: SwapRoute,
val swapAmount: LocalFiat,
val feeAmount: LocalFiat?,
val swapId: SwapId,
val verifiedState: VerifiedState,
+ /**
+ * Verified exchange data over the full amount (swap + fee), keyed to the source mint. Required
+ * by the server when initializing a new reserve currency from a source mint other than the core
+ * mint (the treasury-funded flow); null otherwise.
+ */
+ val fullAmountExchangeData: ExchangeData.Verified? = null,
) {
val fundingIntentId: List
- get() = when (kind) {
- is SwapStartKind.Reserve -> kind.fundingIntentId
- is SwapStartKind.Stablecoin -> kind.fundingIntentId
+ get() = when (program) {
+ is SwapProgram.Reserve -> program.fundingIntentId
+ is SwapProgram.Stablecoin -> program.fundingIntentId
}
val totalTransferAmount: LocalFiat
@@ -31,7 +37,7 @@ data class StatefulSwapRequest(
}
}
-sealed interface SwapStartKind {
+sealed interface SwapProgram {
/**
* Server parameters for starting swaps against the Reserve program
*/
@@ -48,7 +54,7 @@ sealed interface SwapStartKind {
* Where "amount" of "from_mint" will be sent from to the VM swap PDA
*/
val fundingSource: SwapFundingSource,
- ) : SwapStartKind {
+ ) : SwapProgram {
val fundingIntentId: List
get() = when (fundingSource) {
is SwapFundingSource.ExternalWallet -> fundingSource.transactionSignature
@@ -78,7 +84,7 @@ sealed interface SwapStartKind {
* Where "amount" of "from_mint" will be sent from to the VM swap PDA
*/
val fundingSource: SwapFundingSource.SubmitIntent,
- ): SwapStartKind {
+ ): SwapProgram {
val fundingIntentId: List = fundingSource.id
}
}
\ No newline at end of file
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt
index 5bb818d8d..6fd02c5b1 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt
@@ -204,6 +204,18 @@ sealed interface StatefulSwapResponseServerParameters {
* Destination account where fee should be paid
*/
val feeDestination: PublicKey,
+ /**
+ * Server-controlled treasury account for flows that require it (e.g.
+ * initializing a new reserve currency outside of the core mint). Null
+ * when the swap is funded directly with the core mint.
+ */
+ val treasury: PublicKey?,
+ /**
+ * The amount of core mint tokens used for the purchase when funding
+ * through the [treasury]. Client should validate this matches a
+ * pre-coordinated amount accepted by the user. Zero when unused.
+ */
+ val treasuryPurchaseAmount: Long,
): StatefulSwapResponseServerParameters
/**
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt
similarity index 62%
rename from services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt
rename to services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt
index b28601313..c2d0f442d 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt
@@ -4,19 +4,22 @@ import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.financial.usdc
import com.getcode.opencode.model.financial.usdf
-sealed interface SwapDirection {
+sealed interface SwapRoute {
// USDC -> Bonded Token
- data class Buy(val mint: MintMetadata): SwapDirection
+ data class Buy(val mint: MintMetadata): SwapRoute
// Bonded Token -> USDC
- data class Sell(val mint: MintMetadata): SwapDirection
+ data class Sell(val mint: MintMetadata): SwapRoute
// USDF -> USDC
- data object WithdrawUsdc: SwapDirection
+ data object WithdrawUsdc: SwapRoute
+ // Arbitrary currency -> currency (neither side the core mint)
+ data class CrossCurrency(val from: MintMetadata, val to: MintMetadata): SwapRoute
val sourceMint: MintMetadata
get() = when (this) {
is Buy -> MintMetadata.usdf
is Sell -> mint
is WithdrawUsdc -> MintMetadata.usdf
+ is CrossCurrency -> from
}
val destinationMint: MintMetadata
@@ -24,5 +27,6 @@ sealed interface SwapDirection {
is Buy -> mint
is Sell -> MintMetadata.usdf
is WithdrawUsdc -> MintMetadata.usdc
+ is CrossCurrency -> to
}
-}
\ No newline at end of file
+}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt
index 9b2b74605..05f893705 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt
@@ -7,6 +7,7 @@ import com.getcode.opencode.model.accounts.AccountCluster
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.StatefulSwapRequest
import com.getcode.opencode.model.transactions.TransactionMetadata
@@ -65,6 +66,20 @@ interface TransactionRepository {
verifiedState: VerifiedState,
): Result
+ suspend fun crossCurrencySwap(
+ scope: CoroutineScope,
+ owner: AccountCluster,
+ amount: LocalFiat,
+ feeAmount: LocalFiat? = null,
+ from: Token,
+ to: Token,
+ verifiedState: VerifiedState,
+ fullAmountExchangeData: ExchangeData.Verified? = null,
+ swapId: SwapId? = null,
+ source: SwapFundingSource = SwapFundingSource.SubmitIntent(),
+ fund: (suspend (StatefulSwapRequest) -> Result)? = null,
+ ): Result
+
suspend fun withdrawUsdf(
scope: CoroutineScope,
amount: LocalFiat,
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt
index b289c69dd..299cd2fe8 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt
@@ -229,8 +229,6 @@ data class SolanaTransaction(val message: Message, val signatures: List() }
val staticAccountKeys = mutableListOf()
- val dynamicWritableAccountKeys = mutableListOf()
- val dynamicReadOnlyAccountKeys = mutableListOf()
var requiredSigners = 0
var readOnlySigners = 0
@@ -252,10 +250,8 @@ data class SolanaTransaction(val message: Message, val signatures: List
+ writableLUTIndexes[lutIndex].map { lut.addresses[it.toInt() and 0xFF] }
+ }
+ val loadedReadonlyAccounts = sortedLuts.withIndex().flatMap { (lutIndex, lut) ->
+ readonlyLUTIndexes[lutIndex].map { lut.addresses[it.toInt() and 0xFF] }
+ }
+ val allAccounts = staticAccountKeys + loadedWritableAccounts + loadedReadonlyAccounts
// Build address table lookups (only include LUTs that are actually used)
val addressTableLookups = mutableListOf()
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt
index e8ca08e5f..64dc824d5 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt
@@ -3,13 +3,15 @@ package com.getcode.opencode.solana
import com.getcode.opencode.internal.solana.model.SwapId
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.usdf
-import com.getcode.opencode.model.transactions.SwapDirection
+import com.getcode.opencode.model.transactions.SwapRoute
import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.transactions.FundSwapPool
+import com.getcode.opencode.solana.swap.buildCrossCurrencyExistingSwapInstructions
import com.getcode.opencode.solana.swap.buildExistingCurrencyBuyInstructions
import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions
import com.getcode.opencode.solana.swap.buildSellInstructions
+import com.getcode.opencode.solana.swap.buildTreasuryFundedNewCurrencyBuyInstructions
import com.getcode.opencode.solana.swap.buildStablecoinSwapperInstructions
import com.getcode.opencode.solana.swap.buildStatelessSwapInstructions
import com.getcode.opencode.solana.swap.buildUsdcDepositInstructions
@@ -25,13 +27,13 @@ object TransactionBuilder {
*
* This function generates a V0 transaction that executes a swap between USDC and another
* supported token. It handles both "Buy" (USDC -> Token) and "Sell" (Token -> USDC) directions
- * by generating the appropriate instruction sets based on the [direction] parameter.
+ * by generating the appropriate instruction sets based on the [route] parameter.
*
* @param response The server parameters required for the swap, containing payer information,
* lookup tables (ALTs), nonce, and a blockhash.
* @param authority The public key of the user authorizing the swap (the wallet owner).
* @param swapAuthority The public key of the temporary swap authority derived from the nonce.
- * @param direction The direction of the swap (Buy or Sell) and the target/source mint involved.
+ * @param route The route of the swap (Buy or Sell) and the target/source mint involved.
* @param amount The amount of tokens to swap (in the source currency's smallest unit).
* @param minOutput The minimum acceptable amount of output tokens to receive (slippage protection).
* Defaults to 0.
@@ -41,36 +43,49 @@ object TransactionBuilder {
response: StatefulSwapResponseServerParameters.ExistingCurrency,
authority: PublicKey,
swapAuthority: PublicKey,
- direction: SwapDirection,
+ route: SwapRoute,
amount: Long,
minOutput: Long = 0,
): SolanaTransaction {
val coreMint = Token.usdf
- val instructions = when (direction) {
- is SwapDirection.Buy -> buildExistingCurrencyBuyInstructions(
+ val instructions = when (route) {
+ is SwapRoute.Buy -> buildExistingCurrencyBuyInstructions(
serverParameters = response,
nonce = response.nonce,
authority = authority,
swapAuthority = swapAuthority,
coreMintMetadata = coreMint,
- targetMintMetadata = direction.mint,
+ targetMintMetadata = route.mint,
amount = amount,
minOutput = minOutput,
)
- is SwapDirection.Sell -> buildSellInstructions(
+ is SwapRoute.Sell -> buildSellInstructions(
serverParameters = response,
nonce = response.nonce,
authority = authority,
swapAuthority = swapAuthority,
- sourceMintMetadata = direction.mint,
+ sourceMintMetadata = route.mint,
coreMintMetadata = coreMint,
amount = amount,
minOutput = minOutput,
)
- SwapDirection.WithdrawUsdc -> throw IllegalArgumentException("Withdraw USDC should not be used with ExistingCurrency params")
+ SwapRoute.WithdrawUsdc -> throw IllegalArgumentException("Withdraw USDC should not be used with ExistingCurrency params")
+
+ // Cross-currency (currency -> currency) swaps between two existing currencies map to the
+ // server's buy+sell handler: sell the source into the core mint, then buy the destination.
+ is SwapRoute.CrossCurrency -> buildCrossCurrencyExistingSwapInstructions(
+ serverParameters = response,
+ nonce = response.nonce,
+ authority = authority,
+ swapAuthority = swapAuthority,
+ fromMintMetadata = route.from,
+ toMintMetadata = route.to,
+ coreMintMetadata = coreMint,
+ amount = amount,
+ )
}
return SolanaTransaction.newV0Instance(
@@ -94,7 +109,7 @@ object TransactionBuilder {
* blockhash, ALTs, fee destination, and the pool fee recipient.
* @param authority The public key of the user authorizing the swap (the wallet owner).
* @param swapAuthority The public key of a random one-time use account that signs the swap.
- * @param direction The direction of the swap (Buy or Sell) and the target/source mint involved.
+ * @param route The route of the swap (Buy or Sell) and the target/source mint involved.
* @param amount The amount of tokens to swap from the source mint (in quarks).
* @param minOutput The minimum acceptable amount of output tokens to receive (slippage protection).
* Defaults to 0.
@@ -105,7 +120,7 @@ object TransactionBuilder {
authority: PublicKey,
swapAuthority: PublicKey,
destinationOwner: PublicKey,
- direction: SwapDirection,
+ route: SwapRoute,
amount: Long,
feeAmount: Long = 0,
minOutput: Long = 0,
@@ -116,8 +131,8 @@ object TransactionBuilder {
authority = authority,
swapAuthority = swapAuthority,
destinationOwner = destinationOwner,
- fromMintMetadata = direction.sourceMint,
- toMintMetadata = direction.destinationMint,
+ fromMintMetadata = route.sourceMint,
+ toMintMetadata = route.destinationMint,
amount = amount,
feeAmount = feeAmount,
minOutput = minOutput,
@@ -150,18 +165,34 @@ object TransactionBuilder {
fun buyNewCurrency(
response: StatefulSwapResponseServerParameters.NewCurrency,
authority: PublicKey,
+ sourceMintMetadata: MintMetadata,
coreMintMetadata: MintMetadata,
amount: Long,
feeAmount: Long?,
): SolanaTransaction {
- val instructions = buildNewCurrencyBuyInstructions(
- serverParameters = response,
- nonce = response.nonce,
- authority = authority,
- coreMintMetadata = coreMintMetadata,
- amount = amount,
- feeAmount = feeAmount ?: 0,
- )
+ // When the server provides a treasury the initial buy is funded from a non-core source mint
+ // (cross-currency), which uses a distinct instruction layout. Otherwise the buy is paid for
+ // directly with the core mint.
+ val instructions = if (response.treasury != null) {
+ buildTreasuryFundedNewCurrencyBuyInstructions(
+ serverParameters = response,
+ nonce = response.nonce,
+ authority = authority,
+ sourceMintMetadata = sourceMintMetadata,
+ coreMintMetadata = coreMintMetadata,
+ swapAmount = amount,
+ feeAmount = feeAmount ?: 0,
+ )
+ } else {
+ buildNewCurrencyBuyInstructions(
+ serverParameters = response,
+ nonce = response.nonce,
+ authority = authority,
+ coreMintMetadata = coreMintMetadata,
+ amount = amount,
+ feeAmount = feeAmount ?: 0,
+ )
+ }
return SolanaTransaction.newV0Instance(
payer = response.payer,
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt
new file mode 100644
index 000000000..5e62880c0
--- /dev/null
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt
@@ -0,0 +1,191 @@
+package com.getcode.opencode.solana.swap
+
+import com.getcode.opencode.internal.solana.extensions.extractServerParameters
+import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts
+import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent
+import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit
+import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice
+import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_BuyAndDepositIntoVm
+import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_SellTokens
+import com.getcode.opencode.internal.solana.programs.MemoProgram_Memo
+import com.getcode.opencode.internal.solana.programs.SystemProgram_AdvanceNonce
+import com.getcode.opencode.internal.solana.programs.TokenProgram_CloseAccount
+import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_CloseSwapAccountIfEmpty
+import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_TransferForSwap
+import com.getcode.opencode.model.financial.MintMetadata
+import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters
+import com.getcode.opencode.solana.Instruction
+import com.getcode.solana.keys.PublicKey
+
+/**
+ * Builds the list of instructions for a cross-currency swap between two existing launchpad
+ * currencies (neither side is the core mint). This mirrors the server's
+ * `ReserveBuySellSwapHandler`.
+ *
+ * The source currency is transferred out of the user's VM swap account into a temporary account
+ * held by the [swapAuthority], sold for the core mint, and the resulting core mint is immediately
+ * spent buying the destination currency, which is deposited into the user's destination VM. The
+ * swap authority is the seller/buyer and owns the temporary accounts (it signs client-side); the
+ * VM authorities are signed server-side.
+ *
+ * Instruction format (matches the server's buy+sell existing currency layout):
+ * 1. System::AdvanceNonce
+ * 2. ComputeBudget::SetComputeUnitLimit
+ * 3. ComputeBudget::SetComputeUnitPrice
+ * 4. Memo::Memo
+ * 5. AssociatedTokenAccount::CreateIdempotent (temporary core mint ATA)
+ * 6. AssociatedTokenAccount::CreateIdempotent (temporary source mint ATA)
+ * 7. VM::TransferForSwap (source VM swap ATA -> temporary source mint ATA)
+ * 8. Reserve::SellTokens (sell source mint -> core mint into the temporary core mint ATA)
+ * 9. Reserve::BuyAndDepositIntoVm (buy destination mint funded by the temporary core mint ATA,
+ * depositing into the destination VM)
+ * 10. Token::CloseAccount (close temporary core mint ATA)
+ * 11. Token::CloseAccount (close temporary source mint ATA)
+ * 12. VM::CloseSwapAccountIfEmpty (close source VM swap ATA if empty)
+ *
+ * @param serverParameters Existing currency server parameters (payer, limits, memory account, etc.).
+ * @param nonce The nonce account to use for the transaction.
+ * @param authority The owner of the user's accounts (the VTA owner and source swapper).
+ * @param swapAuthority The temporary holder that executes the sell/buy and owns the temporary ATAs.
+ * @param fromMintMetadata Metadata for the source currency being sold.
+ * @param toMintMetadata Metadata for the destination currency being bought.
+ * @param coreMintMetadata Metadata for the core mint (the reserve base currency).
+ * @param amount The amount of the source currency to swap.
+ * @return A list of [Instruction]s to execute the cross-currency swap.
+ */
+internal fun buildCrossCurrencyExistingSwapInstructions(
+ serverParameters: StatefulSwapResponseServerParameters.ExistingCurrency,
+ nonce: PublicKey,
+ authority: PublicKey,
+ swapAuthority: PublicKey,
+ fromMintMetadata: MintMetadata,
+ toMintMetadata: MintMetadata,
+ coreMintMetadata: MintMetadata,
+ amount: Long,
+): List {
+ val sourceVm = fromMintMetadata.vmMetadata
+ val destinationVm = toMintMetadata.vmMetadata
+
+ val sourceLaunchpad = fromMintMetadata.launchpadMetadata
+ ?: throw IllegalStateException("source mint has no launchpad metadata")
+ val destinationLaunchpad = toMintMetadata.launchpadMetadata
+ ?: throw IllegalStateException("destination mint has no launchpad metadata")
+
+ val serverParams = extractServerParameters(serverParameters)
+
+ val sourceTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority)
+
+ val createTemporaryCoreMintAta = AssociatedTokenProgram_CreateIdempotent(
+ subsidizer = serverParams.payer,
+ owner = swapAuthority,
+ mint = coreMintMetadata.address,
+ )
+
+ val createTemporarySourceCurrencyAta = AssociatedTokenProgram_CreateIdempotent(
+ subsidizer = serverParams.payer,
+ owner = swapAuthority,
+ mint = fromMintMetadata.address,
+ )
+
+ return buildList {
+ // 1. System::AdvanceNonce
+ add(SystemProgram_AdvanceNonce(nonce, serverParams.payer).instruction())
+
+ // 2. ComputeBudget::SetComputeUnitLimit
+ add(ComputeBudgetProgram_SetComputeUnitLimit(units = serverParams.computeUnitLimit).instruction())
+
+ // 3. ComputeBudget::SetComputeUnitPrice
+ add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = serverParams.computeUnitPrice).instruction())
+
+ // 4. Memo::Memo
+ add(MemoProgram_Memo(message = serverParams.memo).instruction())
+
+ // 5. AssociatedTokenAccount::CreateIdempotent (temporary core mint ATA)
+ add(createTemporaryCoreMintAta.instruction())
+
+ // 6. AssociatedTokenAccount::CreateIdempotent (temporary source mint ATA)
+ add(createTemporarySourceCurrencyAta.instruction())
+
+ // 7. VM::TransferForSwap (source VM swap ATA -> temporary source mint ATA)
+ add(
+ VirtualMachineProgram_TransferForSwap(
+ vmAuthority = sourceVm.authority,
+ vm = sourceVm.vm,
+ swapper = authority,
+ swapPda = sourceTimelockAccounts.pda.publicKey,
+ swapAta = sourceTimelockAccounts.ata.publicKey,
+ destination = createTemporarySourceCurrencyAta.address,
+ amount = amount,
+ bump = sourceTimelockAccounts.pda.bump,
+ ).instruction()
+ )
+
+ // 8. Reserve::SellTokens (sell source mint -> core mint into the temporary core mint ATA)
+ add(
+ CurrencyCreatorProgram_SellTokens(
+ inAmount = amount,
+ minOutAmount = 0,
+ seller = swapAuthority,
+ pool = sourceLaunchpad.liquidityPool,
+ targetMint = fromMintMetadata.address,
+ baseMint = coreMintMetadata.address,
+ vaultTarget = sourceLaunchpad.mintVault,
+ vaultBase = sourceLaunchpad.coreMintVault,
+ sellerTarget = createTemporarySourceCurrencyAta.address,
+ sellerBase = createTemporaryCoreMintAta.address,
+ ).instruction()
+ )
+
+ // 9. Reserve::BuyAndDepositIntoVm (buy destination mint funded by the temporary core mint ATA)
+ add(
+ CurrencyCreatorProgram_BuyAndDepositIntoVm(
+ inAmount = 0,
+ minOutAmount = 0,
+ vmMemoryIndex = serverParams.memoryIndex,
+ buyer = swapAuthority,
+ pool = destinationLaunchpad.liquidityPool,
+ targetMint = toMintMetadata.address,
+ baseMint = coreMintMetadata.address,
+ vaultTarget = destinationLaunchpad.mintVault,
+ vaultBase = destinationLaunchpad.coreMintVault,
+ buyerBase = createTemporaryCoreMintAta.address,
+ vmAuthority = destinationVm.authority,
+ vm = destinationVm.vm,
+ vmMemory = serverParams.memoryAccount,
+ vmOmnibus = destinationVm.omnibus,
+ vtaOwner = authority,
+ ).instruction()
+ )
+
+ // 10. Token::CloseAccount (close temporary core mint ATA)
+ add(
+ TokenProgram_CloseAccount(
+ account = createTemporaryCoreMintAta.address,
+ destination = serverParams.payer,
+ owner = swapAuthority,
+ ).instruction()
+ )
+
+ // 11. Token::CloseAccount (close temporary source mint ATA)
+ add(
+ TokenProgram_CloseAccount(
+ account = createTemporarySourceCurrencyAta.address,
+ destination = serverParams.payer,
+ owner = swapAuthority,
+ ).instruction()
+ )
+
+ // 12. VM::CloseSwapAccountIfEmpty (close source VM swap ATA if empty)
+ add(
+ VirtualMachineProgram_CloseSwapAccountIfEmpty(
+ vmAuthority = sourceVm.authority,
+ vm = sourceVm.vm,
+ swapper = authority,
+ swapPda = sourceTimelockAccounts.pda.publicKey,
+ swapAta = sourceTimelockAccounts.ata.publicKey,
+ destination = serverParams.payer,
+ bump = sourceTimelockAccounts.pda.bump,
+ ).instruction()
+ )
+ }
+}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt
index 5197a8389..b2fcfc36c 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt
@@ -15,6 +15,7 @@ import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetCom
import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_BuyTokens
import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_InitializeCurrency
import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_InitializePool
+import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_SellTokens
import com.getcode.opencode.internal.solana.programs.SystemProgram_AdvanceNonce
import com.getcode.opencode.internal.solana.programs.TokenProgram_CloseAccount
import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_InitVm
@@ -213,3 +214,159 @@ internal fun buildNewCurrencyBuyInstructions(
)
}
}
+
+/**
+ * Builds the list of instructions for the treasury-funded new currency purchase flow, used when
+ * the initial buy for a new currency is funded from a source mint other than the core mint (i.e.
+ * a cross-currency purchase). This mirrors the server's
+ * `ReserveTreasuryFundedCreateAndBuySwapHandler`.
+ *
+ * Unlike [buildNewCurrencyBuyInstructions], the destination currency, its VM, and the buyer's
+ * destination VM deposit ATA are initialized in a prior transaction, so they are only referenced
+ * here. The full swap + fee amount of the source mint is collected into a server-controlled
+ * treasury, sold for the core mint (proceeds routed to the fee destination), and the new currency
+ * is then bought using the treasury's own core mint holdings for a pre-coordinated
+ * [StatefulSwapResponseServerParameters.NewCurrency.treasuryPurchaseAmount]. The treasury signs the
+ * sell/buy instructions server-side; the client only signs as the swapper.
+ *
+ * Instruction format (matches the server's "all other cases" reserve new currency layout):
+ * 1. System::AdvanceNonce
+ * 2. ComputeBudget::SetComputeUnitLimit
+ * 3. ComputeBudget::SetComputeUnitPrice
+ * 4. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA)
+ * 5. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee))
+ * 6. Reserve::SellTokens (sell the full swap + fee amount, core mint proceeds -> fee destination)
+ * 7. Reserve::BuyTokens (buy funded by the treasury's core mint ATA -> to_mint VM deposit ATA)
+ *
+ * @param serverParameters Parameters provided by the server for the new currency flow. Must carry a
+ * non-null [StatefulSwapResponseServerParameters.NewCurrency.treasury].
+ * @param nonce The nonce account to use for the transaction.
+ * @param authority The currency creator / buyer (owner == swapAuthority for the new currency flow).
+ * @param sourceMintMetadata Metadata for the source (from) mint being sold to fund the purchase.
+ * @param coreMintMetadata Metadata for the core mint (the reserve base currency).
+ * @param swapAmount The amount of the source mint to swap (in quarks).
+ * @param feeAmount The fee amount of the source mint to collect (in quarks).
+ * @return A list of [Instruction]s to execute the treasury-funded new currency buy.
+ */
+internal fun buildTreasuryFundedNewCurrencyBuyInstructions(
+ serverParameters: StatefulSwapResponseServerParameters.NewCurrency,
+ nonce: PublicKey,
+ authority: PublicKey,
+ sourceMintMetadata: MintMetadata,
+ coreMintMetadata: MintMetadata,
+ swapAmount: Long,
+ feeAmount: Long,
+): List {
+ val serverParams = extractServerParameters(serverParameters)
+
+ val treasury = serverParams.treasury
+ ?: throw IllegalStateException("treasury is required for the treasury-funded new currency buy")
+ val purchaseCoreQuarks = serverParams.treasuryPurchaseAmount
+
+ val sourceLaunchpad = sourceMintMetadata.launchpadMetadata
+ ?: throw IllegalStateException("source mint has no launchpad metadata")
+ val sourceVm = sourceMintMetadata.vmMetadata
+
+ // Derive the destination (new) currency accounts deterministically from the server params.
+ // The currency, pool, VM and deposit ATA are initialized in a prior transaction and only
+ // referenced here.
+ val targetMint = PublicKey.deriveCurrencyMintAddress(
+ authority = serverParams.authority,
+ name = serverParams.name,
+ seed = serverParams.seed,
+ ).publicKey
+
+ val currencyConfig = PublicKey.deriveCurrencyConfigAddress(targetMint).publicKey
+ val pool = PublicKey.deriveLiquidityPoolAddress(currencyConfig).publicKey
+ val vaultTarget = PublicKey.deriveVaultAddress(pool, targetMint).publicKey
+ val vaultBase = PublicKey.deriveVaultAddress(pool, coreMintMetadata.address).publicKey
+
+ val vm = PublicKey.deriveVirtualMachineAccount(
+ mint = targetMint,
+ authority = serverParams.authority,
+ lockout = serverParams.vmLockDurationInDays.toUByte(),
+ ).publicKey
+ val depositPda = PublicKey.deriveDepositAccount(vm, authority)
+ val vmDepositAta = AssociatedTokenProgram_CreateIdempotent(
+ subsidizer = serverParams.payer,
+ owner = depositPda.publicKey,
+ mint = targetMint,
+ ).address
+
+ // Treasury accounts. The from_mint ATA is opened by this transaction; the core mint ATA is
+ // pre-funded server-side and only referenced.
+ val createTreasuryFromMintAta = AssociatedTokenProgram_CreateIdempotent(
+ subsidizer = serverParams.payer,
+ owner = treasury,
+ mint = sourceMintMetadata.address,
+ )
+ val treasuryCoreMintAta = AssociatedTokenProgram_CreateIdempotent(
+ subsidizer = serverParams.payer,
+ owner = treasury,
+ mint = coreMintMetadata.address,
+ ).address
+
+ val sourceTimelockAccounts = sourceMintMetadata.timelockSwapAccounts(authority)
+
+ return buildList {
+ // 1. System::AdvanceNonce
+ add(SystemProgram_AdvanceNonce(nonce, serverParams.payer).instruction())
+
+ // 2. ComputeBudget::SetComputeUnitLimit
+ add(ComputeBudgetProgram_SetComputeUnitLimit(units = serverParams.computeUnitLimit).instruction())
+
+ // 3. ComputeBudget::SetComputeUnitPrice
+ add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = serverParams.computeUnitPrice).instruction())
+
+ // 4. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA)
+ add(createTreasuryFromMintAta.instruction())
+
+ // 5. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee))
+ add(
+ VirtualMachineProgram_TransferForSwapWithFee(
+ vmAuthority = sourceVm.authority,
+ vm = sourceVm.vm,
+ swapper = authority,
+ swapPda = sourceTimelockAccounts.pda.publicKey,
+ swapAta = sourceTimelockAccounts.ata.publicKey,
+ destination = createTreasuryFromMintAta.address,
+ feeDestination = createTreasuryFromMintAta.address,
+ swapAmount = swapAmount,
+ feeAmount = feeAmount,
+ bump = sourceTimelockAccounts.pda.bump,
+ ).instruction()
+ )
+
+ // 6. Reserve::SellTokens (sell full swap + fee of from_mint; core mint proceeds -> fee destination)
+ add(
+ CurrencyCreatorProgram_SellTokens(
+ inAmount = swapAmount + feeAmount,
+ minOutAmount = 0,
+ seller = treasury,
+ pool = sourceLaunchpad.liquidityPool,
+ targetMint = sourceMintMetadata.address,
+ baseMint = coreMintMetadata.address,
+ vaultTarget = sourceLaunchpad.mintVault,
+ vaultBase = sourceLaunchpad.coreMintVault,
+ sellerTarget = createTreasuryFromMintAta.address,
+ sellerBase = serverParams.feeDestination,
+ ).instruction()
+ )
+
+ // 7. Reserve::BuyTokens (buy funded by the treasury's core mint ATA -> to_mint VM deposit ATA)
+ add(
+ CurrencyCreatorProgram_BuyTokens(
+ inAmount = purchaseCoreQuarks,
+ minOutAmount = 0,
+ buyer = treasury,
+ pool = pool,
+ targetMint = targetMint,
+ baseMint = coreMintMetadata.address,
+ vaultTarget = vaultTarget,
+ vaultBase = vaultBase,
+ buyerTarget = vmDepositAta,
+ buyerBase = treasuryCoreMintAta,
+ ).instruction()
+ )
+ }
+}
diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt
new file mode 100644
index 000000000..6d12bf00f
--- /dev/null
+++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt
@@ -0,0 +1,77 @@
+package com.getcode.opencode.model.financial
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+
+/**
+ * Fiat-domain port of iOS's `LaunchpadSellFeeTests`. Where iOS asserts on token
+ * quarks, we assert on the fiat value (Android computes the fee in fiat, not
+ * token quarks — the pool deducts the real fee on-chain).
+ */
+class LaunchpadSellFeeTest {
+
+ @Test
+ fun `fee is bps of the amount`() {
+ val gross = Fiat(20.20)
+
+ val fee = gross.launchpadSellFee(bps = 100)
+
+ // 1% of 20.20 = 0.202 → "$0.20"
+ assertEquals(gross.quarks / 100, fee.quarks)
+ assertEquals("$0.20", fee.formatted())
+ }
+
+ @Test
+ fun `a sub-cent fee displays as zero`() {
+ val tiny = Fiat(0.00005)
+
+ val fee = tiny.launchpadSellFee(bps = 100)
+
+ // Android rounds fiat micro-units rather than flooring token quarks
+ // (iOS's model), but a sub-threshold fee still shows as $0.00.
+ assertEquals("$0.00", fee.formatted())
+ assertFalse(fee.hasDisplayableValue)
+ }
+
+ @Test
+ fun `grossing up nets back to the original after the fee`() {
+ assertEquals("$20.20", Fiat(20.0).grossingUpLaunchpadSellFee(bps = 100).formatted())
+ assertEquals("$10.10", Fiat(10.0).grossingUpLaunchpadSellFee(bps = 100).formatted())
+ assertEquals("$0.01", Fiat(0.01).grossingUpLaunchpadSellFee(bps = 100).formatted())
+ }
+
+ @Test
+ fun `buy maximum shape - full balance nets to balance times one minus fee`() {
+ val balance = Fiat(20.0)
+
+ val fee = balance.launchpadSellFee(bps = 100)
+ val net = balance - fee
+
+ assertEquals("$0.20", fee.formatted())
+ assertEquals("$19.80", net.formatted())
+ }
+
+ @Test
+ fun `grossing up a 100 percent fee returns the amount unchanged instead of dividing by zero`() {
+ val net = Fiat(20.0)
+
+ assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 10_000))
+ }
+
+ @Test
+ fun `bps above 100 percent is clamped rather than producing a negative gross-up`() {
+ val net = Fiat(20.0)
+
+ // Clamped to 10_000 → guard returns self, never a negative divisor.
+ assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 12_000))
+ }
+
+ @Test
+ fun `zero bps is a no-op`() {
+ val net = Fiat(20.0)
+
+ assertEquals(Fiat.Zero.quarks, net.launchpadSellFee(bps = 0).quarks)
+ assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 0))
+ }
+}
diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt
new file mode 100644
index 000000000..3fd449ec5
--- /dev/null
+++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt
@@ -0,0 +1,77 @@
+package com.getcode.opencode.solana
+
+import com.getcode.opencode.model.transactions.AddressLookupTable
+import com.getcode.solana.keys.AccountMeta
+import com.getcode.solana.keys.Hash
+import com.getcode.solana.keys.PublicKey
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SolanaTransactionLookupTableTest {
+
+ // Seed lives in byte 0 (rest zero), so lexicographic ordering tracks the
+ // seed value — enough to make the two tables' accounts sort out of table
+ // order.
+ private fun publicKey(seed: Int): PublicKey {
+ val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 }
+ return PublicKey(bytes.toList())
+ }
+
+ private fun hash(seed: Int): Hash {
+ val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 }
+ return Hash(bytes.toList())
+ }
+
+ @Test
+ fun `multi-table lookups compile instruction indexes in table-grouped order`() {
+ // Loaded accounts resolve on-chain grouped BY TABLE (every table's
+ // writables, then every table's readonlys) — not in global sort order.
+ // Arrange keys so the two orderings differ: the second table's accounts
+ // sort lexicographically BEFORE the first table's.
+ val payer = publicKey(1)
+ val program = publicKey(2)
+ val writableA = publicKey(40) // in table A (sorts after writableB)
+ val writableB = publicKey(30) // in table B
+ val readonlyA = publicKey(41) // in table A (sorts after readonlyB)
+ val readonlyB = publicKey(31) // in table B
+
+ val tableA = AddressLookupTable(publicKey(10), listOf(writableA, readonlyA))
+ val tableB = AddressLookupTable(publicKey(11), listOf(writableB, readonlyB))
+
+ val instruction = Instruction(
+ program = program,
+ accounts = listOf(
+ AccountMeta.writable(writableA),
+ AccountMeta.writable(writableB),
+ AccountMeta.readonly(readonlyA),
+ AccountMeta.readonly(readonlyB),
+ ),
+ data = listOf(7.toByte()),
+ )
+
+ val transaction = SolanaTransaction.newV0Instance(
+ payer = payer,
+ recentBlockhash = hash(9),
+ addressLookupTables = listOf(tableA, tableB),
+ instructions = listOf(instruction),
+ )
+
+ val message = (transaction.message as Message.VersionedV0).message
+
+ // Static: payer(0), program(1). Loaded: tableA.writable(2),
+ // tableB.writable(3), tableA.readonly(4), tableB.readonly(5).
+ assertEquals(listOf(payer, program), message.staticAccountKeys)
+ assertEquals(
+ listOf(2, 3, 4, 5),
+ message.instructions[0].accountIndexes,
+ )
+
+ assertEquals(2, message.addressLookupTables.size)
+ assertEquals(tableA.publicKey, message.addressLookupTables[0].publicKey)
+ assertEquals(listOf(0), message.addressLookupTables[0].writableIndexes)
+ assertEquals(listOf(1), message.addressLookupTables[0].readonlyIndexes)
+ assertEquals(tableB.publicKey, message.addressLookupTables[1].publicKey)
+ assertEquals(listOf(0), message.addressLookupTables[1].writableIndexes)
+ assertEquals(listOf(1), message.addressLookupTables[1].readonlyIndexes)
+ }
+}