From 0899785732ce319edc801f70b17c9cd73d0c5d17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 16:30:18 -0400 Subject: [PATCH 1/6] chore(protos): update opencode protobuf definitions Adds full_amount_exchange_data to the reserve swap client parameters and treasury/treasury_purchase_amount to the reserve new currency server parameters for treasury-funded cross-currency swaps. Signed-off-by: Brandon McAnsh --- .../v1/ocp_transaction_service.proto | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) 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 From d17982e0a646dfef296628a5a290bbce8c7e9a7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 16:30:28 -0400 Subject: [PATCH 2/6] feat(opencode): support treasury-funded cross-currency new currency swaps Handle the reserve new currency flow when the initial buy is funded from a source mint other than the core mint. The full swap + fee amount is collected into a server-controlled treasury, sold for the core mint (proceeds routed to the fee destination), and the new currency is bought using the treasury's own core mint holdings for a pre-coordinated amount. Mirrors the server's ReserveTreasuryFundedCreateAndBuySwapHandler; the treasury signs the sell/buy instructions server-side while the client signs only as the swapper. - Map treasury/treasuryPurchaseAmount into the NewCurrency server params and thread them through the extracted server parameters - Add buildTreasuryFundedNewCurrencyBuyInstructions and dispatch to it from TransactionBuilder.buyNewCurrency when a treasury is present - Add the fullAmountExchangeData request field and serialize it in the reserve client parameters when set - Cover the new instruction builder with count/program/account tests Signed-off-by: Brandon McAnsh --- .../getcode/opencode/solana/swap/SwapTests.kt | 98 ++++++++++- .../network/api/intents/IntentStatefulSwap.kt | 1 + .../network/extensions/LocalToProtobuf.kt | 8 + .../network/extensions/ProtobufToLocal.kt | 2 + .../extensions/ExtractServerParameters.kt | 6 +- .../model/transactions/StatefulSwapRequest.kt | 6 + .../StatefulSwapResponseServerParameters.kt | 12 ++ .../opencode/solana/TransactionBuilder.kt | 33 +++- .../solana/swap/NewCurrencyBuyInstructions.kt | 157 ++++++++++++++++++ 9 files changed, 313 insertions(+), 10 deletions(-) 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..121b600ca 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 @@ -351,7 +351,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 +546,93 @@ 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 testBuildNewCurrencyBuyInstructionInitializeCurrencyData() { val totalAmount = 100_000L 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..e2f4e66df 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 @@ -53,6 +53,7 @@ internal class IntentStatefulSwap( is StatefulSwapResponseServerParameters.NewCurrency -> TransactionBuilder.buyNewCurrency( response = parameters, authority = request.owner.authorityPublicKey, + sourceMintMetadata = request.direction.sourceMint, coreMintMetadata = Token.usdf, amount = request.swapAmount.underlyingTokenAmount.quarks, feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks, 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..439cb10c3 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 @@ -290,6 +290,14 @@ internal fun StatefulSwapRequest.currencyCreatorParams(): OcpTransactionService. .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 -> { 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/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/transactions/StatefulSwapRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt index 9d1474115..c341490d8 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 @@ -17,6 +17,12 @@ data class StatefulSwapRequest( 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) { 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/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index e8ca08e5f..7899f63a2 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 @@ -10,6 +10,7 @@ import com.getcode.opencode.model.transactions.FundSwapPool 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 @@ -150,18 +151,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/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() + ) + } +} From c0562f134369072581e374ef4d848d86394d7f20 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 16:39:37 -0400 Subject: [PATCH 3/6] refactor(opencode): rename swap types to SwapProgram and SwapRoute StatefulSwapRequest carried two sealed types that both drifted toward the generic word 'kind' on different axes. Give each a name for its actual axis: - SwapStartKind (Reserve | Stablecoin) -> SwapProgram; field kind -> program (which on-chain program the swap runs against) - SwapDirection (Buy | Sell | WithdrawUsdc | Swap) -> SwapRoute; field direction -> route (the source -> destination route) Also rename the cross-currency route SwapRoute.Swap -> SwapRoute.CrossCurrency to say what distinguishes it from Buy/Sell (neither side is the core mint). The cross-currency existing currency swap path is not built client-side yet and throws NotImplementedError. Signed-off-by: Brandon McAnsh --- .../network/api/intents/IntentStatefulSwap.kt | 16 +++++----- .../network/executors/StatefulSwapExecutor.kt | 22 ++++++------- .../network/extensions/LocalToProtobuf.kt | 22 ++++++------- .../internal/network/funding/SwapFunding.kt | 2 +- .../network/services/TransactionService.kt | 16 +++++----- .../model/transactions/StatefulSwapRequest.kt | 16 +++++----- .../{SwapDirection.kt => SwapRoute.kt} | 14 +++++--- .../opencode/solana/TransactionBuilder.kt | 32 +++++++++++-------- 8 files changed, 74 insertions(+), 66 deletions(-) rename services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/{SwapDirection.kt => SwapRoute.kt} (62%) 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 e2f4e66df..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,13 +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.direction.sourceMint, + sourceMintMetadata = request.route.sourceMint, coreMintMetadata = Token.usdf, amount = request.swapAmount.underlyingTokenAmount.quarks, feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks, @@ -63,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 @@ -81,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 439cb10c3..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,8 +282,8 @@ 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()) @@ -320,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) @@ -349,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/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..9976264bf 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 @@ -25,9 +25,9 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.minus 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 +196,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 +226,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, @@ -254,13 +254,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/model/transactions/StatefulSwapRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt index c341490d8..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,8 +11,8 @@ 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, @@ -25,9 +25,9 @@ data class StatefulSwapRequest( 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 @@ -37,7 +37,7 @@ data class StatefulSwapRequest( } } -sealed interface SwapStartKind { +sealed interface SwapProgram { /** * Server parameters for starting swaps against the Reserve program */ @@ -54,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 @@ -84,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/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/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index 7899f63a2..6d1bfb024 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,7 +3,7 @@ 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 @@ -26,13 +26,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. @@ -42,36 +42,40 @@ 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, whose client-side instruction builder is not implemented yet. + is SwapRoute.CrossCurrency -> throw NotImplementedError("Cross-currency existing currency swaps are not supported client-side yet") } return SolanaTransaction.newV0Instance( @@ -95,7 +99,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. @@ -106,7 +110,7 @@ object TransactionBuilder { authority: PublicKey, swapAuthority: PublicKey, destinationOwner: PublicKey, - direction: SwapDirection, + route: SwapRoute, amount: Long, feeAmount: Long = 0, minOutput: Long = 0, @@ -117,8 +121,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, From 3727ac30a93f7430c40e292a5bd7d2ff8606bd22 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 17:41:25 -0400 Subject: [PATCH 4/6] feat(opencode): build cross-currency existing swap transactions Implement the client instruction builder for swaps between two existing launchpad currencies (neither side the core mint), mirroring the server's ReserveBuySellSwapHandler: transfer the source out of the user's VM swap account into a temporary account held by the swap authority, sell it for the core mint, then buy and deposit the destination currency into the user's destination VM. Dispatch to it from TransactionBuilder.swap for the SwapRoute.CrossCurrency case (previously threw NotImplementedError), and cover the builder with count/program/account tests. Signed-off-by: Brandon McAnsh --- .../getcode/opencode/solana/swap/SwapTests.kt | 131 ++++++++++++ .../opencode/solana/TransactionBuilder.kt | 14 +- .../CrossCurrencyExistingSwapInstructions.kt | 191 ++++++++++++++++++ 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt 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 121b600ca..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, @@ -633,6 +667,103 @@ class SwapInstructionsTest { 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/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index 6d1bfb024..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 @@ -7,6 +7,7 @@ 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 @@ -74,8 +75,17 @@ object TransactionBuilder { 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, whose client-side instruction builder is not implemented yet. - is SwapRoute.CrossCurrency -> throw NotImplementedError("Cross-currency existing currency swaps are not supported client-side yet") + // 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( 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() + ) + } +} From 1557e5e2777d7b4f641aafd8b3056fb6616c9bad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 17:50:49 -0400 Subject: [PATCH 5/6] feat(opencode): add cross-currency swap initiation to the service layer Expose a crossCurrencySwap entry point through the controller, repository and service that swaps one launchpad currency directly for another. It builds a Reserve stateful swap with a SwapRoute.CrossCurrency route and branches on the destination: - existing destination -> one-time swap authority; server returns existing currency parameters (buy+sell handler) - freshly-launched stub destination -> owner == swap authority and a fullAmountExchangeData valuation over the full swap + fee amount; server returns treasury-funded new currency parameters The response builders for both paths already exist, so this closes the request-side gap. The caller supplies fullAmountExchangeData (USD-valued, keyed to the source mint) for the new-currency case. Signed-off-by: Brandon McAnsh --- .../controllers/TransactionController.kt | 57 ++++++++++++++ .../controllers/TransactionOperations.kt | 19 +++++ .../InternalTransactionRepository.kt | 27 +++++++ .../network/services/TransactionService.kt | 51 ++++++++++++ .../getcode/opencode/model/financial/Fiat.kt | 4 + .../model/financial/LaunchpadSellFee.kt | 40 ++++++++++ .../opencode/model/financial/LocalFiat.kt | 7 ++ .../repositories/TransactionRepository.kt | 15 ++++ .../opencode/solana/SolanaTransaction.kt | 20 +++-- .../model/financial/LaunchpadSellFeeTest.kt | 77 +++++++++++++++++++ .../SolanaTransactionLookupTableTest.kt | 77 +++++++++++++++++++ 11 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt 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/services/TransactionService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt index 9976264bf..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,6 +23,7 @@ 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.SwapRoute @@ -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, 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/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/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) + } +} From cbe0084dc442cb939637f55a4cac8a1f765e84ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 17 Jul 2026 13:23:36 -0400 Subject: [PATCH 6/6] feat(tokens): cross-currency buy UI and receipt flow Client layer for buying a launchpad currency with another launchpad currency, on top of the opencode cross-currency swap service. - Buy receipt screen with a "You Pay" (grossed-up) / "You Receive" breakdown plus the amount-to-buy and exchange-fee line items. - "Insufficient Balance After Fees" modal that auto-shows on landing the receipt when the pool fee pushes the pay total past the funding token's wallet balance, offering to cap the buy to the maximum affordable amount (display-only correction; the buy already caps to the balance on-chain). - SwapViewModel: gross up the entered amount by the funding pool's sell fee (guarding the 100% edge), and show the amount received of the target token on the success screen. Co-Authored-By: Claude Opus 4.8 --- .../com/flipcash/app/core/tokens/SwapStep.kt | 8 + .../flipcash/app/core/tokens/TokenPurpose.kt | 4 + .../flipcash/app/core/ui/TokenBalanceRow.kt | 20 +- .../core/src/main/res/values/strings.xml | 13 +- .../flipcash/app/tokens/SwapEntryScreen.kt | 8 + .../com/flipcash/app/tokens/SwapFlowScreen.kt | 43 +++ .../app/tokens/TokenBuyReceiptScreen.kt | 48 +++ .../flipcash/app/tokens/TokenSelectScreen.kt | 83 +++--- .../tokens/internal/TokenBuyReceiptScreen.kt | 217 ++++++++++++++ .../app/tokens/internal/TokenSelectScreen.kt | 9 + .../tokens/internal/TokenSellReceiptScreen.kt | 2 +- .../app/tokens/ui/SelectTokenViewModel.kt | 18 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 275 ++++++++++++++---- .../com/flipcash/app/tokens/ui/TokenList.kt | 6 + 14 files changed, 661 insertions(+), 93 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt 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)