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) + } +}