From 0899785732ce319edc801f70b17c9cd73d0c5d17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 16 Jul 2026 16:30:18 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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) + } +}