From 64d693f121b7cce3ce0d706b58fed7dc8efbd0cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 11:13:10 -0400 Subject: [PATCH 1/6] feat(opencode): build CoinbaseStableSwapper SwapV2 for server-mediated stablecoin swaps Add the SwapV2 instruction (no whitelist account, 15 accounts) and send swap_version = 2 on the stateful and stateless stablecoin initiate requests. The request and the locally built instruction both read CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION so they cannot drift. The client-built deposit paths keep the legacy Swap. --- .../api/intents/IntentStatelessSwap.kt | 18 +++-- .../network/extensions/LocalToProtobuf.kt | 2 + .../programs/CoinbaseStableSwapperProgram.kt | 8 ++ ...baseStableSwapperProgram_SwapForVersion.kt | 45 +++++++++++ .../CoinbaseStableSwapperProgram_SwapV2.kt | 63 +++++++++++++++ .../CoinbaseStablecoinSwapperInstructions.kt | 12 ++- .../swap/UsdcDepositSweepInstructions.kt | 9 ++- .../CoinbaseStableSwapperProgramTest.kt | 63 +++++++++++++++ ...inbaseStablecoinSwapperInstructionsTest.kt | 15 +++- .../swap/StablecoinSwapVersionRequestTest.kt | 79 +++++++++++++++++++ .../swap/StatelessSwapInstructionsTest.kt | 14 +++- 11 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StablecoinSwapVersionRequestTest.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt index f4219e2fd2..5d0d80d156 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt @@ -4,6 +4,7 @@ import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService import com.getcode.opencode.internal.network.extensions.asSignature import com.getcode.opencode.internal.network.extensions.asSolanaAccountId import com.getcode.opencode.internal.network.extensions.sign +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.usdc import com.getcode.opencode.model.financial.usdf @@ -33,17 +34,20 @@ internal class IntentStatelessSwap( ) } + internal fun stablecoinParams(): OcpTransactionService.StatelessSwapRequest.Initiate.CoinbaseStableSwapperClientParameters = + OcpTransactionService.StatelessSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.newBuilder() + .setFromMint(request.fromMint.asSolanaAccountId()) + .setToMint(request.toMint.asSolanaAccountId()) + .setSwapAmount(request.amount) + // Same constant the transaction builder defaults to, so request and local build agree. + .setSwapVersion(CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION) + .build() + fun initiate(): OcpTransactionService.StatelessSwapRequest { val owner = request.owner.authority.keyPair val initiate = OcpTransactionService.StatelessSwapRequest.Initiate.newBuilder() .setOwner(owner.asSolanaAccountId()) - .setStablecoin( - OcpTransactionService.StatelessSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.newBuilder() - .setFromMint(request.fromMint.asSolanaAccountId()) - .setToMint(request.toMint.asSolanaAccountId()) - .setSwapAmount(request.amount) - .build() - ) + .setStablecoin(stablecoinParams()) .setWaitForFinalization(request.waitForFinalization) .apply { setSignature(sign(owner)) } .build() 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 e737a1a9e5..377ea7a5ff 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 @@ -9,6 +9,7 @@ import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.OpenAccountsMetadata.AccountSet import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram import com.getcode.opencode.model.accounts.AccountType import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.LocalFiat @@ -372,6 +373,7 @@ internal fun StatefulSwapRequest.stablecoinParams(): OcpTransactionService.State .setFeeAmount(this@stablecoinParams.feeAmount?.underlyingTokenAmount?.quarks ?: 0) .setFundingSource(OcpTransactionService.FundingSource.FUNDING_SOURCE_SUBMIT_INTENT) .setFundingId(details.fundingSource.id.base58) + .setSwapVersion(CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION) } } } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram.kt index bff8dbaa8f..79c4f24dc2 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram.kt @@ -5,5 +5,13 @@ import com.getcode.solana.keys.PublicKey internal class CoinbaseStableSwapperProgram { companion object { val address = PublicKey.fromBase58("pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F") + + /** + * The `swap_version` sent on server-mediated stablecoin swap requests, and the instruction + * the client builds locally for the same transaction. Both must come from this one value: + * the server builds with the version we request, so a mismatch fails the swap. + * Exactly 2 selects SwapV2; anything else (including unset) is the legacy Swap. + */ + const val SERVER_SWAP_VERSION: Int = 2 } } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt new file mode 100644 index 0000000000..f4bf49e0a4 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt @@ -0,0 +1,45 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.opencode.solana.Instruction +import com.getcode.solana.keys.PublicKey + +/** + * Builds the server-mediated stablecoin swap instruction for [swapVersion]: SwapV2 for exactly 2 + * (matching the server's rule), otherwise the legacy Swap with [whitelist]. + */ +internal fun coinbaseStableSwapperSwapInstruction( + swapVersion: Int, + pool: PublicKey, + inVault: PublicKey, + outVault: PublicKey, + inVaultTokenAccount: PublicKey, + outVaultTokenAccount: PublicKey, + userFromTokenAccount: PublicKey, + toTokenAccount: PublicKey, + feeRecipientTokenAccount: PublicKey, + feeRecipient: PublicKey, + fromMint: PublicKey, + toMint: PublicKey, + user: PublicKey, + whitelist: PublicKey, + amountIn: Long, + minAmountOut: Long, +): Instruction = if (swapVersion == 2) { + CoinbaseStableSwapperProgram_SwapV2( + pool = pool, inVault = inVault, outVault = outVault, + inVaultTokenAccount = inVaultTokenAccount, outVaultTokenAccount = outVaultTokenAccount, + userFromTokenAccount = userFromTokenAccount, toTokenAccount = toTokenAccount, + feeRecipientTokenAccount = feeRecipientTokenAccount, feeRecipient = feeRecipient, + fromMint = fromMint, toMint = toMint, user = user, + amountIn = amountIn, minAmountOut = minAmountOut, + ).instruction() +} else { + CoinbaseStableSwapperProgram_Swap( + pool = pool, inVault = inVault, outVault = outVault, + inVaultTokenAccount = inVaultTokenAccount, outVaultTokenAccount = outVaultTokenAccount, + userFromTokenAccount = userFromTokenAccount, toTokenAccount = toTokenAccount, + feeRecipientTokenAccount = feeRecipientTokenAccount, feeRecipient = feeRecipient, + fromMint = fromMint, toMint = toMint, user = user, whitelist = whitelist, + amountIn = amountIn, minAmountOut = minAmountOut, + ).instruction() +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt new file mode 100644 index 0000000000..b3957b88be --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt @@ -0,0 +1,63 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.opencode.solana.Instruction +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.bytes + +/** + * CoinbaseStableSwapper::SwapV2. Same arguments as [CoinbaseStableSwapperProgram_Swap], but the + * deprecated whitelist account is gone (15 accounts instead of 16). + */ +internal class CoinbaseStableSwapperProgram_SwapV2( + private val pool: PublicKey, + private val inVault: PublicKey, + private val outVault: PublicKey, + private val inVaultTokenAccount: PublicKey, + private val outVaultTokenAccount: PublicKey, + private val userFromTokenAccount: PublicKey, + private val toTokenAccount: PublicKey, + private val feeRecipientTokenAccount: PublicKey, + private val feeRecipient: PublicKey, + private val fromMint: PublicKey, + private val toMint: PublicKey, + private val user: PublicKey, + private val amountIn: Long, + private val minAmountOut: Long, +) : InstructionType { + override fun instruction(): Instruction { + return Instruction( + program = CoinbaseStableSwapperProgram.address, + accounts = listOf( + AccountMeta.readonly(publicKey = pool), + AccountMeta.readonly(publicKey = inVault), + AccountMeta.readonly(publicKey = outVault), + AccountMeta.writable(publicKey = inVaultTokenAccount), + AccountMeta.writable(publicKey = outVaultTokenAccount), + AccountMeta.writable(publicKey = userFromTokenAccount), + AccountMeta.writable(publicKey = toTokenAccount), + AccountMeta.writable(publicKey = feeRecipientTokenAccount), + AccountMeta.readonly(publicKey = feeRecipient), + AccountMeta.readonly(publicKey = fromMint), + AccountMeta.readonly(publicKey = toMint), + AccountMeta.writable(publicKey = user, signer = true), + AccountMeta.readonly(publicKey = TokenProgram.address), + AccountMeta.readonly(publicKey = AssociatedTokenProgram.address), + AccountMeta.readonly(publicKey = SystemProgram.address), + ), + data = encode(), + ) + } + + override fun encode(): List = buildList { + addAll(DISCRIMINATOR.toList()) + addAll(amountIn.bytes) + addAll(minAmountOut.bytes) + } + + companion object { + private val DISCRIMINATOR = byteArrayOf( + 43, 4, 237.toByte(), 11, 26, 201.toByte(), 30, 98, + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt index 3d78a61738..1f2159b341 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt @@ -3,7 +3,8 @@ package com.getcode.opencode.solana.swap import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram +import com.getcode.opencode.internal.solana.programs.coinbaseStableSwapperSwapInstruction 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.MemoProgram_Memo @@ -41,6 +42,7 @@ import com.getcode.solana.keys.PublicKey * @param amount The amount of from_mint tokens to swap (in quarks). * @param feeAmount The fee amount deducted from the VM transfer and sent to the fee destination. * @param minOutput The minimum acceptable amount of to_mint tokens to receive (slippage protection). + * @param swapVersion Swap instruction version; must equal the `swap_version` sent on the initiate request. * @return A list of [Instruction]s to execute the stablecoin swap. */ internal fun buildStablecoinSwapperInstructions( @@ -54,6 +56,7 @@ internal fun buildStablecoinSwapperInstructions( amount: Long, feeAmount: Long, minOutput: Long, + swapVersion: Int = CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, ): List { val fromVm = fromMintMetadata.vmMetadata val fromTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority) @@ -115,9 +118,10 @@ internal fun buildStablecoinSwapperInstructions( ).instruction() ) - // 8. CoinbaseStableSwapper::Swap (from_mint swap authority ATA -> to_mint destination owner ATA) + // 8. CoinbaseStableSwapper::Swap or ::SwapV2 per [swapVersion] (from_mint swap authority ATA -> to_mint destination owner ATA) add( - CoinbaseStableSwapperProgram_Swap( + coinbaseStableSwapperSwapInstruction( + swapVersion = swapVersion, pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -133,7 +137,7 @@ internal fun buildStablecoinSwapperInstructions( whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = minOutput, - ).instruction() + ) ) // 9. Token::CloseAccount (closes swap authority's from_mint ATA) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt index 6dcff213eb..5d275335d5 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt @@ -5,7 +5,8 @@ import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram +import com.getcode.opencode.internal.solana.programs.coinbaseStableSwapperSwapInstruction 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.MemoProgram_Memo @@ -35,6 +36,7 @@ internal fun buildStatelessSwapInstructions( fromMint: MintMetadata, toMint: MintMetadata, amount: Long, + swapVersion: Int = CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, ): List { val toVm = requireNotNull(toMint.vmMetadata) { "Destination mint missing VM metadata: ${toMint.symbol}" @@ -98,7 +100,8 @@ internal fun buildStatelessSwapInstructions( // 5. CoinbaseStableSwapper::Swap (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) add( - CoinbaseStableSwapperProgram_Swap( + coinbaseStableSwapperSwapInstruction( + swapVersion = swapVersion, pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -114,7 +117,7 @@ internal fun buildStatelessSwapInstructions( whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = amount, // 1:1 stable pair - ).instruction() + ) ) } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt index 92bbfff95f..ec06e4653c 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt @@ -202,4 +202,67 @@ class CoinbaseStableSwapperProgramTest { } } } + + // --- SwapV2 --- + + private val goldenKeys = (0..12).map { i -> + PublicKey(ByteArray(32) { if (it == 0) (i + 1).toByte() else 0 }.toList()) + } + + private fun goldenV1() = CoinbaseStableSwapperProgram_Swap( + pool = goldenKeys[0], inVault = goldenKeys[1], outVault = goldenKeys[2], + inVaultTokenAccount = goldenKeys[3], outVaultTokenAccount = goldenKeys[4], + userFromTokenAccount = goldenKeys[5], toTokenAccount = goldenKeys[6], + feeRecipientTokenAccount = goldenKeys[7], feeRecipient = goldenKeys[8], + fromMint = goldenKeys[9], toMint = goldenKeys[10], + user = goldenKeys[11], whitelist = goldenKeys[12], + amountIn = 1_500_000L, minAmountOut = 1_490_000L, + ).instruction() + + private fun goldenV2() = CoinbaseStableSwapperProgram_SwapV2( + pool = goldenKeys[0], inVault = goldenKeys[1], outVault = goldenKeys[2], + inVaultTokenAccount = goldenKeys[3], outVaultTokenAccount = goldenKeys[4], + userFromTokenAccount = goldenKeys[5], toTokenAccount = goldenKeys[6], + feeRecipientTokenAccount = goldenKeys[7], feeRecipient = goldenKeys[8], + fromMint = goldenKeys[9], toMint = goldenKeys[10], + user = goldenKeys[11], + amountIn = 1_500_000L, minAmountOut = 1_490_000L, + ).instruction() + + @Test + fun swapV2GoldenData() { + val expected = listOf( + 43, 4, 237, 11, 26, 201, 30, 98, + 0x60, 0xe3, 0x16, 0, 0, 0, 0, 0, + 0x50, 0xbc, 0x16, 0, 0, 0, 0, 0, + ).map { it.toByte() } + assertEquals(expected, goldenV2().data) + } + + @Test + fun swapV2ArgsMatchV1() { + assertEquals(goldenV1().data.drop(8), goldenV2().data.drop(8)) + assertTrue(goldenV1().data.take(8) != goldenV2().data.take(8)) + } + + @Test + fun swapV2AccountsAreV1WithoutWhitelist() { + val v1 = goldenV1() + val v2 = goldenV2() + assertEquals(16, v1.accounts.size) + assertEquals(goldenKeys[12], v1.accounts[12].publicKey) + assertEquals(15, v2.accounts.size) + assertEquals(v1.accounts.filterIndexed { i, _ -> i != 12 }, v2.accounts) + assertEquals(CoinbaseStableSwapperProgram.address, v2.program) + } + + @Test + fun swapV2TailAccountsAndFlags() { + val a = goldenV2().accounts + assertEquals(TokenProgram.address, a[12].publicKey) + assertEquals(AssociatedTokenProgram.address, a[13].publicKey) + assertEquals(SystemProgram.address, a[14].publicKey) + assertEquals(setOf(11), a.indices.filter { a[it].isSigner }.toSet()) + assertEquals(setOf(3, 4, 5, 6, 7, 11), a.indices.filter { a[it].isWritable }.toSet()) + } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt index 00896e1d95..5266e7408a 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt @@ -89,6 +89,7 @@ class CoinbaseStablecoinSwapperInstructionsTest { amount: Long = 1_000_000L, feeAmount: Long = 50_000L, minOutput: Long = 990_000L, + swapVersion: Int = 2, ) = buildStablecoinSwapperInstructions( serverParameters = serverParameters, nonce = nonce, @@ -100,6 +101,7 @@ class CoinbaseStablecoinSwapperInstructionsTest { amount = amount, feeAmount = feeAmount, minOutput = minOutput, + swapVersion = swapVersion, ) // --- Instruction count --- @@ -255,9 +257,20 @@ class CoinbaseStablecoinSwapperInstructionsTest { } @Test - fun `swap instruction has correct whitelist PDA`() { + fun `swap instruction is SwapV2 without whitelist by default`() { val ix = buildInstructions()[7] + assertEquals(15, ix.accounts.size) + assertEquals(CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, 2) + assertEquals(listOf(43, 4, -19, 11, 26, -55, 30, 98), ix.data.take(8)) + val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey + assertTrue(ix.accounts.none { it.publicKey == whitelist }) + } + + @Test + fun `swap instruction has whitelist PDA when swapVersion is 1`() { + val ix = buildInstructions(swapVersion = 1)[7] val expectedWhitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey + assertEquals(16, ix.accounts.size) assertEquals(expectedWhitelist, ix.accounts[12].publicKey) } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StablecoinSwapVersionRequestTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StablecoinSwapVersionRequestTest.kt new file mode 100644 index 0000000000..ad8168edc2 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StablecoinSwapVersionRequestTest.kt @@ -0,0 +1,79 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.api.intents.IntentStatelessSwap +import com.getcode.opencode.internal.network.extensions.stablecoinParams +import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.transactions.StatefulSwapRequest +import com.getcode.opencode.model.transactions.StatelessSwapRequest +import com.getcode.opencode.model.transactions.SwapFundingSource +import com.getcode.opencode.model.transactions.SwapProgram +import com.getcode.opencode.model.transactions.SwapRoute +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The server builds the stablecoin swap with the version we request and we rebuild it locally, so + * the request's swap_version and the locally built instruction must come from one value. + */ +class StablecoinSwapVersionRequestTest { + + private fun key(seed: Int) = PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + @Test + fun `swap version constant is 2`() { + assertEquals(2, CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION) + } + + @Test + fun `stateful stablecoin initiate params carry swap_version 2`() { + val request = StatefulSwapRequest( + owner = mockk(relaxed = true), + swapAuthority = mockk(relaxed = true) { + every { publicKeyBytes } returns ByteArray(32) { 21 } + }, + program = SwapProgram.Stablecoin( + fromMint = key(1), + toMint = key(2), + destinationOwner = key(3), + fundingSource = SwapFundingSource.SubmitIntent(key(30).bytes), + ), + route = mockk(relaxed = true), + swapAmount = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 1_000_000), + nativeAmount = Fiat(quarks = 1_000_000), + rate = Rate.oneToOne, + mint = Mint.usdf, + ), + feeAmount = null, + swapId = SwapId(key(31).bytes), + verifiedState = mockk(relaxed = true), + ) + + assertEquals(2, request.stablecoinParams().build().swapVersion) + } + + @Test + fun `stateless stablecoin initiate params carry swap_version 2`() { + val intent = IntentStatelessSwap( + StatelessSwapRequest( + owner = mockk(relaxed = true), + fromMint = Mint(key(1).bytes), + toMint = Mint(key(2).bytes), + amount = 1_000_000, + ) + ) + + assertEquals(2, intent.stablecoinParams().swapVersion) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt index 37cb7acfee..6f8f94db53 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt @@ -92,12 +92,14 @@ class StatelessSwapInstructionsTest { private fun buildInstructions( amount: Long = 1_000_000L, params: StatelessSwapServerParameters = serverParameters, + swapVersion: Int = 2, ) = buildStatelessSwapInstructions( serverParameters = params, owner = owner, fromMint = fromMintMetadata, toMint = toMintMetadata, amount = amount, + swapVersion = swapVersion, ) // --- Instruction count --- @@ -297,9 +299,19 @@ class StatelessSwapInstructionsTest { } @Test - fun `swap instruction has correct whitelist PDA`() { + fun `swap instruction is SwapV2 without whitelist by default`() { val ix = buildInstructions()[4] + assertEquals(15, ix.accounts.size) + assertEquals(listOf(43, 4, -19, 11, 26, -55, 30, 98), ix.data.take(8)) + val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey + assertTrue(ix.accounts.none { it.publicKey == whitelist }) + } + + @Test + fun `swap instruction has whitelist PDA when swapVersion is 1`() { + val ix = buildInstructions(swapVersion = 1)[4] val expectedWhitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey + assertEquals(16, ix.accounts.size) assertEquals(expectedWhitelist, ix.accounts[12].publicKey) } From 3fef1e0cf19dcc4f9cbc646723e4a492642187f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 12:58:52 -0400 Subject: [PATCH 2/6] chore(deps): bump ocp-client-protocol to 0.9.0 0.9.0 carries swap_version on both CoinbaseStableSwapper initiate params. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fef8a2bd1a..ff83cf456c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,7 +68,7 @@ protovalidate-kt = "0.1.3" # cadence and there is nothing to keep aligned. # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. -ocp-client-protocol = "0.8.0" +ocp-client-protocol = "0.9.0" flipcash2-client-protocol = "0.20.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's From 566f3da55358a38e95f0503d1268b51cbfcb86e4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 14:25:54 -0400 Subject: [PATCH 3/6] feat(opencode): build SwapV2 for client-built deposit swaps The USDF deposit and the USDC to USDF fund swap both moved from Swap to SwapV2, which drops the whitelist account. The deposit transaction fixture is regenerated from the builder; the v1 bytes it replaces were mainnet-simulated, and the v2 bytes are not yet. --- .../solana/swap/UsdcToUsdfSwapInstructions.kt | 7 ++- .../solana/swap/UsdfDepositInstructions.kt | 9 ++-- .../swap/ClientBuiltDepositSwapV2Test.kt | 44 +++++++++++++++++++ ...oinbaseStablecoinDepositTransactionTest.kt | 34 ++++++++------ 4 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/ClientBuiltDepositSwapV2Test.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcToUsdfSwapInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcToUsdfSwapInstructions.kt index 2d4d2b27df..492214d17c 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcToUsdfSwapInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcToUsdfSwapInstructions.kt @@ -4,7 +4,7 @@ import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_SwapV2 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.MemoProgram_Memo @@ -68,7 +68,7 @@ internal fun buildUsdcToUsdfSwapInstructions( ).instruction() ) - // 7. Swap (USDC ATA -> USDF ATA) + // 7. Swap (USDC ATA -> USDF ATA); the Coinbase pool uses CoinbaseStableSwapper::SwapV2 when (pool) { is FundSwapPool.Usdf -> { add( @@ -87,7 +87,7 @@ internal fun buildUsdcToUsdfSwapInstructions( is FundSwapPool.CoinbaseStableSwapper -> { val swapAccounts = CoinbaseSwapAccounts.derive(Mint.usdc, Mint.usdf) add( - CoinbaseStableSwapperProgram_Swap( + CoinbaseStableSwapperProgram_SwapV2( pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -103,7 +103,6 @@ internal fun buildUsdcToUsdfSwapInstructions( fromMint = Mint.usdc, toMint = Mint.usdf, user = sender, - whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = amount, // 1:1 stable pair; the program rejects 0 with InvalidAmount ).instruction() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt index 0fd537cd85..f423ca5962 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdfDepositInstructions.kt @@ -5,7 +5,7 @@ import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_SwapV2 import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice import com.getcode.opencode.internal.solana.vmAuthority @@ -24,7 +24,7 @@ import com.getcode.solana.keys.PublicKey * 1. ComputeBudget::SetComputeUnitLimit(200_000) * 2. ComputeBudget::SetComputeUnitPrice(1_000) * 3. AssociatedTokenAccount::CreateIdempotent (owner's USDF VM deposit PDA ATA — Phantom pays rent) - * 4. CoinbaseStableSwapper::Swap (sender's USDC ATA → owner's USDF VM deposit PDA ATA) + * 4. CoinbaseStableSwapper::SwapV2 (sender's USDC ATA → owner's USDF VM deposit PDA ATA) * * The Geyser watcher monitors the deposit PDA ATA and transfers USDF * into the VM once funds land — same path as the stateless sweep. @@ -82,9 +82,9 @@ internal fun buildUsdfDepositInstructions( mint = Mint.usdf, ).instruction() ) - // 4. CoinbaseStableSwapper::Swap (sender's USDC ATA → owner's USDF VM deposit PDA ATA) + // 4. CoinbaseStableSwapper::SwapV2 (sender's USDC ATA → owner's USDF VM deposit PDA ATA) add( - CoinbaseStableSwapperProgram_Swap( + CoinbaseStableSwapperProgram_SwapV2( pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -97,7 +97,6 @@ internal fun buildUsdfDepositInstructions( fromMint = Mint.usdc, toMint = Mint.usdf, user = sender, - whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = amount, // 1:1 stable pair ).instruction() diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/ClientBuiltDepositSwapV2Test.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/ClientBuiltDepositSwapV2Test.kt new file mode 100644 index 0000000000..57f3114507 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/ClientBuiltDepositSwapV2Test.kt @@ -0,0 +1,44 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram +import com.getcode.opencode.model.transactions.FundSwapPool +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The client-built deposit swaps must use CoinbaseStableSwapper SwapV2: 15 accounts, v2 discriminator. */ +class ClientBuiltDepositSwapV2Test { + + private val sender = PublicKey(ByteArray(32) { 3 }.toList()) + private val owner = PublicKey(ByteArray(32) { 7 }.toList()) + private val feeRecipient = PublicKey(ByteArray(32) { 5 }.toList()) + private val v2Discriminator = listOf(43, 4, -19, 11, 26, -55, 30, 98) + + @Test + fun `usdf deposit swap instruction is SwapV2`() { + val ix = buildUsdfDepositInstructions( + sender = sender, + owner = owner, + amount = 1_000_000L, + feeRecipient = feeRecipient, + ).single { it.program == CoinbaseStableSwapperProgram.address } + + assertEquals(15, ix.accounts.size) + assertEquals(v2Discriminator, ix.data.take(8)) + } + + @Test + fun `usdc to usdf coinbase swap instruction is SwapV2`() { + val ix = buildUsdcToUsdfSwapInstructions( + sender = sender, + owner = owner, + amount = 1_000_000L, + pool = FundSwapPool.CoinbaseStableSwapper(feeRecipient = feeRecipient), + swapId = SwapId(PublicKey(ByteArray(32) { 9 }.toList())), + ).single { it.program == CoinbaseStableSwapperProgram.address } + + assertEquals(15, ix.accounts.size) + assertEquals(v2Discriminator, ix.data.take(8)) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt index 6248ab8ef6..554093ef30 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt @@ -20,10 +20,13 @@ import kotlin.test.assertTrue * valid-looking [PublicKey] and a transaction that encodes fine, so only the end-to-end bytes * catch it. * - * The fixture below was accepted by `simulateTransaction` on mainnet (no error, 64422 compute - * units, `Swapped 5000000 tokens`). Two earlier shapes of this same transaction were rejected - * there: `fee_recipient` read at the offset the published IDL implies fails with + * The deposit swap is `swap_v2` (15 accounts, no whitelist). The expected bytes are + * builder-derived: there is no on-chain `swap_v2` capture for this transaction yet. The previous + * `swap` (v1) version of these bytes was accepted by `simulateTransaction` on mainnet (no error, + * 64422 compute units, `Swapped 5000000 tokens`). Two earlier shapes of that transaction were + * rejected there: `fee_recipient` read at the offset the published IDL implies fails with * `ConstraintAddress` (2012), and `minAmountOut = 0` fails with `InvalidAmount` (6002). + * Re-simulate the v2 bytes before relying on them as a mainnet-accepted vector. */ class CoinbaseStablecoinDepositTransactionTest { @@ -46,21 +49,24 @@ class CoinbaseStablecoinDepositTransactionTest { private val blockhash = Hash(ByteArray(32) { 99 }.toList()) private val amount = 5_000_000L - /** Any change here is a wire-format change on the deposit path. Re-simulate before updating. */ + /** + * Builder-derived `swap_v2` bytes; see the class comment. Any change here is a wire-format + * change on the deposit path. Re-simulate before updating. + */ private val expectedTransactionBase64 = - "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAPFgCi9Dy6" + + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAOFQCi9Dy6" + "TYs7wKM+kEYccLhPtMSNYE1wUvMQtohQE/WACCBGwXmMQDQ0qkChD8P24pvvO8cn53TnEdWfDF4qcEUITd86ORI7wd27l38gKTT3" + "ojsmucS0ZlOGpp5sm3vBtGbwtkF5L6Cvq8w77bPnPNPBRTgo/TPhsNVWk8JRRULgiBrs5GFDW1LEB5bkqApFskZwilGMY+qfcCAS" + "Kl0CEqDasAlxgEeKAYkvEA0wv4eaENRzq1p4QtFatVIft1mOcv1BxHqEPYi++WSNjiQnW4G/HLlzdds46oPbue+ighXzAAAAAAAA" + - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAA+/7dMeCXcpTlNjHO5mhTiv" + - "EMVIGVamVD65t7F9VMsYF6x59+V9mYRAdEA3nhzuBFUPeM3TLK71ctavlwzvl5wrihFyRC8DGIR3MAeAqjMI/sw+jMJK5RKyf9C3" + - "uhVGKjT4z8SXtrVTfBhZj7LzSPTgCpHoi6cjfPqXvflOJvRBPdPEgIo9T7OfrsnLpJtaY0pS7eEVSPUkqiDyzUbvgHGVur3fbwX8" + - "hBrwhMiwez2F9VqDBFKemuE5F9sZF3qiyLAL9R/5yXsykNTLL2XYLZNgA2ZHIRl2NJrr0fTq8UW2xvp6877brTo9ZfNqq8l0MbG7" + - "5MLS9uDkfKYCA0UvXWEDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAVKU1D4XciC1hSlVnJ4iilt3x6rq9CmBniISTL0" + - "7vagBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkMQVks6uA/heUvYKn86iKm18aeqHA3x3bBSlnDQ9z8RIyXJY9OJInx" + - "uz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2MIEQAFAkANAwARAAkD6AMAAAAA" + - "AAAVBwAEAA0HEwgBARUHAAUODQcTCAEBFQcAAwAQBxMIAQESACtjR2ZIaUM2S2dnM0ZwRlp2Z3dHY3N3c0NSdHA0YUJQMmZ6dVhS" + - "UVBpenVOFBAPCgsBAgMEBgwQDQAJExUHGPjGnpHhdYfIQEtMAAAAAABAS0wAAAAAABMDBAUACQNAS0wAAAAAAAA=" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAABesefflfZmEQHRAN54c7gRV" + + "D3jN0yyu9XLWr5cM75ecK4oRckQvAxiEdzAHgKozCP7MPozCSuUSsn/Qt7oVRio0+M/El7a1U3wYWY+y80j04AqR6IunI3z6l735" + + "Tib0QT3TxICKPU+zn67Jy6SbWmNKUu3hFUj1JKog8s1G74Bxlbq9328F/IQa8ITIsHs9hfVagwRSnprhORfbGRd6osiwC/Uf+cl7" + + "MpDUyy9l2C2TYANmRyEZdjSa69H06vFFtsb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hAwZGb+UhFzL/7K26csOb57yM" + + "5bvF9xJrLEObOkAAAAAFSlNQ+F3IgtYUpVZyeIopbd8eq6vQpgZ4iEky9O72oAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+" + + "/wCpDEFZLOrgP4XlL2Cp/OoiptfGnqhwN8d2wUpZw0Pc/ESMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WWNjY2NjY2Nj" + + "Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjCBAABQJADQMAEAAJA+gDAAAAAAAAFAcABAAMBxIIAQEUBwAFDQwHEggBARQHAAMADwcS" + + "CAEBEQArY0dmSGlDNktnZzNGcEZadmd3R2Nzd3NDUnRwNGFCUDJmenVYUlFQaXp1ThMPDgkKAQIDBAYLDwwAEhQHGCsE7QsayR5i" + + "QEtMAAAAAABAS0wAAAAAABIDBAUACQNAS0wAAAAAAAA=" private fun pool(): FundSwapPool.CoinbaseStableSwapper = FundSwapPool.CoinbaseStableSwapper.fromAccountData( From a3161ea86f2fefc33297b5086c905d2405797004 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 14:42:49 -0400 Subject: [PATCH 4/6] refactor(opencode): remove the v1 CoinbaseStableSwapper swap path --- .../internal/solana/extensions/PublicKey.kt | 7 - .../solana/model/CoinbaseSwapAccounts.kt | 4 - .../CoinbaseStableSwapperProgram_Swap.kt | 63 ------ ...baseStableSwapperProgram_SwapForVersion.kt | 45 ---- .../CoinbaseStablecoinSwapperInstructions.kt | 13 +- .../swap/UsdcDepositSweepInstructions.kt | 14 +- .../solana/extensions/PdaDerivationTest.kt | 23 +- .../CoinbaseStableSwapperProgramTest.kt | 206 +----------------- ...inbaseStablecoinSwapperInstructionsTest.kt | 15 +- .../swap/StatelessSwapInstructionsTest.kt | 15 +- 10 files changed, 17 insertions(+), 388 deletions(-) delete mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_Swap.kt delete mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/PublicKey.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/PublicKey.kt index a17f9d2460..766c543ae1 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/PublicKey.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/PublicKey.kt @@ -172,13 +172,6 @@ internal fun PublicKey.Companion.deriveCoinbaseVaultTokenAccountAddress(vault: P ) } -internal fun PublicKey.Companion.deriveCoinbaseWhitelistAddress(): ProgramDerivedAccount { - return findProgramAddress( - seeds = listOf("address_whitelist".toByteArray(Charsets.UTF_8)), - programId = CoinbaseStableSwapperProgram.address, - ) -} - /// FindProgramAddress mirrors the implementation of the Solana SDK's FindProgramAddress. Its primary /// use case (for Kin and Agora) is for deriving associated accounts. /// diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/CoinbaseSwapAccounts.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/CoinbaseSwapAccounts.kt index 3c9fdeb3d9..9f6ddc6f43 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/CoinbaseSwapAccounts.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/CoinbaseSwapAccounts.kt @@ -4,7 +4,6 @@ import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseTokenVaultAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseVaultTokenAccountAddress -import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseWhitelistAddress import com.getcode.solana.keys.PublicKey internal data class CoinbaseSwapAccounts( @@ -13,7 +12,6 @@ internal data class CoinbaseSwapAccounts( val outVault: PublicKey, val inVaultTokenAccount: PublicKey, val outVaultTokenAccount: PublicKey, - val whitelist: PublicKey, ) { fun feeRecipientTokenAccount(feeRecipient: PublicKey, fromMint: PublicKey): PublicKey { return PublicKey.deriveAssociatedAccount( @@ -29,7 +27,6 @@ internal data class CoinbaseSwapAccounts( val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMint).publicKey val inVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey val outVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey - val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey return CoinbaseSwapAccounts( pool = pool, @@ -37,7 +34,6 @@ internal data class CoinbaseSwapAccounts( outVault = outVault, inVaultTokenAccount = inVaultTokenAccount, outVaultTokenAccount = outVaultTokenAccount, - whitelist = whitelist, ) } } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_Swap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_Swap.kt deleted file mode 100644 index c156d67ed9..0000000000 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_Swap.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.getcode.opencode.internal.solana.programs - -import com.getcode.opencode.solana.Instruction -import com.getcode.solana.keys.AccountMeta -import com.getcode.solana.keys.PublicKey -import com.getcode.utils.bytes - -internal class CoinbaseStableSwapperProgram_Swap( - private val pool: PublicKey, - private val inVault: PublicKey, - private val outVault: PublicKey, - private val inVaultTokenAccount: PublicKey, - private val outVaultTokenAccount: PublicKey, - private val userFromTokenAccount: PublicKey, - private val toTokenAccount: PublicKey, - private val feeRecipientTokenAccount: PublicKey, - private val feeRecipient: PublicKey, - private val fromMint: PublicKey, - private val toMint: PublicKey, - private val user: PublicKey, - private val whitelist: PublicKey, - private val amountIn: Long, - private val minAmountOut: Long, -) : InstructionType { - override fun instruction(): Instruction { - return Instruction( - program = CoinbaseStableSwapperProgram.address, - accounts = listOf( - AccountMeta.readonly(publicKey = pool), - AccountMeta.readonly(publicKey = inVault), - AccountMeta.readonly(publicKey = outVault), - AccountMeta.writable(publicKey = inVaultTokenAccount), - AccountMeta.writable(publicKey = outVaultTokenAccount), - AccountMeta.writable(publicKey = userFromTokenAccount), - AccountMeta.writable(publicKey = toTokenAccount), - AccountMeta.writable(publicKey = feeRecipientTokenAccount), - AccountMeta.readonly(publicKey = feeRecipient), - AccountMeta.readonly(publicKey = fromMint), - AccountMeta.readonly(publicKey = toMint), - AccountMeta.writable(publicKey = user, signer = true), - AccountMeta.readonly(publicKey = whitelist), - AccountMeta.readonly(publicKey = TokenProgram.address), - AccountMeta.readonly(publicKey = AssociatedTokenProgram.address), - AccountMeta.readonly(publicKey = SystemProgram.address), - ), - data = encode(), - ) - } - - override fun encode(): List = buildList { - // 8-byte Anchor discriminator: [248, 198, 158, 145, 225, 117, 135, 200] - addAll(DISCRIMINATOR.toList()) - addAll(amountIn.bytes) - addAll(minAmountOut.bytes) - } - - companion object { - private val DISCRIMINATOR = byteArrayOf( - 248.toByte(), 198.toByte(), 158.toByte(), 145.toByte(), - 225.toByte(), 117.toByte(), 135.toByte(), 200.toByte(), - ) - } -} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt deleted file mode 100644 index f4bf49e0a4..0000000000 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapForVersion.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.getcode.opencode.internal.solana.programs - -import com.getcode.opencode.solana.Instruction -import com.getcode.solana.keys.PublicKey - -/** - * Builds the server-mediated stablecoin swap instruction for [swapVersion]: SwapV2 for exactly 2 - * (matching the server's rule), otherwise the legacy Swap with [whitelist]. - */ -internal fun coinbaseStableSwapperSwapInstruction( - swapVersion: Int, - pool: PublicKey, - inVault: PublicKey, - outVault: PublicKey, - inVaultTokenAccount: PublicKey, - outVaultTokenAccount: PublicKey, - userFromTokenAccount: PublicKey, - toTokenAccount: PublicKey, - feeRecipientTokenAccount: PublicKey, - feeRecipient: PublicKey, - fromMint: PublicKey, - toMint: PublicKey, - user: PublicKey, - whitelist: PublicKey, - amountIn: Long, - minAmountOut: Long, -): Instruction = if (swapVersion == 2) { - CoinbaseStableSwapperProgram_SwapV2( - pool = pool, inVault = inVault, outVault = outVault, - inVaultTokenAccount = inVaultTokenAccount, outVaultTokenAccount = outVaultTokenAccount, - userFromTokenAccount = userFromTokenAccount, toTokenAccount = toTokenAccount, - feeRecipientTokenAccount = feeRecipientTokenAccount, feeRecipient = feeRecipient, - fromMint = fromMint, toMint = toMint, user = user, - amountIn = amountIn, minAmountOut = minAmountOut, - ).instruction() -} else { - CoinbaseStableSwapperProgram_Swap( - pool = pool, inVault = inVault, outVault = outVault, - inVaultTokenAccount = inVaultTokenAccount, outVaultTokenAccount = outVaultTokenAccount, - userFromTokenAccount = userFromTokenAccount, toTokenAccount = toTokenAccount, - feeRecipientTokenAccount = feeRecipientTokenAccount, feeRecipient = feeRecipient, - fromMint = fromMint, toMint = toMint, user = user, whitelist = whitelist, - amountIn = amountIn, minAmountOut = minAmountOut, - ).instruction() -} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt index 1f2159b341..61d16ffce0 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt @@ -3,8 +3,7 @@ package com.getcode.opencode.solana.swap import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram -import com.getcode.opencode.internal.solana.programs.coinbaseStableSwapperSwapInstruction +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_SwapV2 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.MemoProgram_Memo @@ -42,7 +41,6 @@ import com.getcode.solana.keys.PublicKey * @param amount The amount of from_mint tokens to swap (in quarks). * @param feeAmount The fee amount deducted from the VM transfer and sent to the fee destination. * @param minOutput The minimum acceptable amount of to_mint tokens to receive (slippage protection). - * @param swapVersion Swap instruction version; must equal the `swap_version` sent on the initiate request. * @return A list of [Instruction]s to execute the stablecoin swap. */ internal fun buildStablecoinSwapperInstructions( @@ -56,7 +54,6 @@ internal fun buildStablecoinSwapperInstructions( amount: Long, feeAmount: Long, minOutput: Long, - swapVersion: Int = CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, ): List { val fromVm = fromMintMetadata.vmMetadata val fromTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority) @@ -118,10 +115,9 @@ internal fun buildStablecoinSwapperInstructions( ).instruction() ) - // 8. CoinbaseStableSwapper::Swap or ::SwapV2 per [swapVersion] (from_mint swap authority ATA -> to_mint destination owner ATA) + // 8. CoinbaseStableSwapper::SwapV2 (from_mint swap authority ATA -> to_mint destination owner ATA) add( - coinbaseStableSwapperSwapInstruction( - swapVersion = swapVersion, + CoinbaseStableSwapperProgram_SwapV2( pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -134,10 +130,9 @@ internal fun buildStablecoinSwapperInstructions( fromMint = fromMintMetadata.address, toMint = toMintMetadata.address, user = swapAuthority, - whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = minOutput, - ) + ).instruction() ) // 9. Token::CloseAccount (closes swap authority's from_mint ATA) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt index 5d275335d5..bca194f3b4 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt @@ -5,8 +5,7 @@ import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent -import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram -import com.getcode.opencode.internal.solana.programs.coinbaseStableSwapperSwapInstruction +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_SwapV2 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.MemoProgram_Memo @@ -24,7 +23,7 @@ import com.getcode.solana.keys.PublicKey * 2. [Optional] ComputeBudget::SetComputeUnitPrice * 3. [Optional] Memo::Memo * 4. AssociatedTokenAccount::CreateIdempotent (open owner's to_mint VM Deposit ATA) - * 5. CoinbaseStableSwapper::Swap (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) + * 5. CoinbaseStableSwapper::SwapV2 (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) * * Used by the on-app-open USDC -> USDF sweep: source is the owner's plain * USDC ATA, destination is the owner's USDF VM Deposit ATA (Geyser-monitored @@ -36,7 +35,6 @@ internal fun buildStatelessSwapInstructions( fromMint: MintMetadata, toMint: MintMetadata, amount: Long, - swapVersion: Int = CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, ): List { val toVm = requireNotNull(toMint.vmMetadata) { "Destination mint missing VM metadata: ${toMint.symbol}" @@ -98,10 +96,9 @@ internal fun buildStatelessSwapInstructions( ).instruction() ) - // 5. CoinbaseStableSwapper::Swap (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) + // 5. CoinbaseStableSwapper::SwapV2 (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) add( - coinbaseStableSwapperSwapInstruction( - swapVersion = swapVersion, + CoinbaseStableSwapperProgram_SwapV2( pool = swapAccounts.pool, inVault = swapAccounts.inVault, outVault = swapAccounts.outVault, @@ -114,10 +111,9 @@ internal fun buildStatelessSwapInstructions( fromMint = fromMint.address, toMint = toMint.address, user = owner, - whitelist = swapAccounts.whitelist, amountIn = amount, minAmountOut = amount, // 1:1 stable pair - ) + ).instruction() ) } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt index 02b327010f..c199c269c9 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt @@ -247,22 +247,6 @@ class PdaDerivationTest { assertNotEquals(result1.publicKey, result2.publicKey) } - // --- deriveCoinbaseWhitelistAddress --- - - @Test - fun coinbaseWhitelistIsDeterministic() { - val result1 = PublicKey.deriveCoinbaseWhitelistAddress() - val result2 = PublicKey.deriveCoinbaseWhitelistAddress() - assertEquals(result1.publicKey, result2.publicKey) - assertEquals(result1.bump, result2.bump) - } - - @Test - fun coinbaseWhitelistIsOffCurve() { - val result = PublicKey.deriveCoinbaseWhitelistAddress() - assertTrue(!Ed25519.onCurve(result.publicKey.bytes.toByteArray())) - } - // --- Coinbase PDA chain: pool -> vault -> vaultTokenAccount --- @Test @@ -271,11 +255,10 @@ class PdaDerivationTest { val mint = testKey(10) val vault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, mint).publicKey val vaultTA = PublicKey.deriveCoinbaseVaultTokenAccountAddress(vault).publicKey - val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey - // All four should be distinct - val all = setOf(pool, vault, vaultTA, whitelist) - assertEquals(4, all.size, "pool, vault, vaultTA, and whitelist should all be distinct") + // All three should be distinct + val all = setOf(pool, vault, vaultTA) + assertEquals(3, all.size, "pool, vault, and vaultTA should all be distinct") } // --- Known value: well-known associated token address --- diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt index ec06e4653c..929a3318f9 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgramTest.kt @@ -22,203 +22,12 @@ class CoinbaseStableSwapperProgramTest { ) } - // --- Swap instruction discriminator --- - - @Test - fun swapDiscriminatorBytes() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 0L, minAmountOut = 0L, - ) - val encoded = ix.encode() - // Anchor discriminator: [248, 198, 158, 145, 225, 117, 135, 200] - assertEquals(248.toByte(), encoded[0]) - assertEquals(198.toByte(), encoded[1]) - assertEquals(158.toByte(), encoded[2]) - assertEquals(145.toByte(), encoded[3]) - assertEquals(225.toByte(), encoded[4]) - assertEquals(117.toByte(), encoded[5]) - assertEquals(135.toByte(), encoded[6]) - assertEquals(200.toByte(), encoded[7]) - } - - // --- Swap instruction encoding --- - - @Test - fun swapEncodeLength() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1000L, minAmountOut = 900L, - ) - // 8 bytes discriminator + 8 bytes amountIn + 8 bytes minAmountOut = 24 - assertEquals(24, ix.encode().size) - } - - @Test - fun swapEncodesAmountsAsLittleEndian() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1_000_000L, minAmountOut = 999_000L, - ) - val encoded = ix.encode() - - // amountIn = 1_000_000 = 0x000F4240 LE → [0x40, 0x42, 0x0F, 0x00, ...] - assertEquals(0x40.toByte(), encoded[8]) - assertEquals(0x42.toByte(), encoded[9]) - assertEquals(0x0F.toByte(), encoded[10]) - for (i in 11..15) assertEquals(0x00.toByte(), encoded[i]) - - // minAmountOut = 999_000 = 0x000F3E58 LE → [0x58, 0x3E, 0x0F, 0x00, ...] - assertEquals(0x58.toByte(), encoded[16]) - assertEquals(0x3E.toByte(), encoded[17]) - assertEquals(0x0F.toByte(), encoded[18]) - for (i in 19..23) assertEquals(0x00.toByte(), encoded[i]) - } - - // --- Swap instruction accounts --- - - @Test - fun swapInstructionHas16Accounts() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1000L, minAmountOut = 900L, - ) - assertEquals(16, ix.instruction().accounts.size) - } - - @Test - fun swapInstructionProgram() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1000L, minAmountOut = 900L, - ) - assertEquals(CoinbaseStableSwapperProgram.address, ix.instruction().program) - } - - @Test - fun swapAccountOrder() { - val keys = (1..13).map { testKey(it) } - val ix = CoinbaseStableSwapperProgram_Swap( - pool = keys[0], inVault = keys[1], outVault = keys[2], - inVaultTokenAccount = keys[3], outVaultTokenAccount = keys[4], - userFromTokenAccount = keys[5], toTokenAccount = keys[6], - feeRecipientTokenAccount = keys[7], feeRecipient = keys[8], - fromMint = keys[9], toMint = keys[10], - user = keys[11], whitelist = keys[12], - amountIn = 1000L, minAmountOut = 900L, - ) - val accounts = ix.instruction().accounts - - // Verify account order matches server: pool, inVault, outVault, inVaultTA, outVaultTA, - // userFromTA, toTA, feeRecipientTA, feeRecipient, fromMint, toMint, user, whitelist, - // tokenProgram, associatedTokenProgram, systemProgram - assertEquals(keys[0], accounts[0].publicKey) // pool - assertEquals(keys[1], accounts[1].publicKey) // inVault - assertEquals(keys[2], accounts[2].publicKey) // outVault - assertEquals(keys[3], accounts[3].publicKey) // inVaultTokenAccount - assertEquals(keys[4], accounts[4].publicKey) // outVaultTokenAccount - assertEquals(keys[5], accounts[5].publicKey) // userFromTokenAccount - assertEquals(keys[6], accounts[6].publicKey) // toTokenAccount - assertEquals(keys[7], accounts[7].publicKey) // feeRecipientTokenAccount - assertEquals(keys[8], accounts[8].publicKey) // feeRecipient - assertEquals(keys[9], accounts[9].publicKey) // fromMint - assertEquals(keys[10], accounts[10].publicKey) // toMint - assertEquals(keys[11], accounts[11].publicKey) // user - assertEquals(keys[12], accounts[12].publicKey) // whitelist - assertEquals(TokenProgram.address, accounts[13].publicKey) - assertEquals(AssociatedTokenProgram.address, accounts[14].publicKey) - assertEquals(SystemProgram.address, accounts[15].publicKey) - } - - @Test - fun swapAccountSignerFlags() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1000L, minAmountOut = 900L, - ) - val accounts = ix.instruction().accounts - - // Only user (index 11) is a signer - for (i in accounts.indices) { - if (i == 11) { - assertTrue(accounts[i].isSigner, "user (index $i) should be signer") - } else { - assertFalse(accounts[i].isSigner, "account at index $i should not be signer") - } - } - } - - @Test - fun swapAccountWritableFlags() { - val ix = CoinbaseStableSwapperProgram_Swap( - pool = testKey(1), inVault = testKey(2), outVault = testKey(3), - inVaultTokenAccount = testKey(4), outVaultTokenAccount = testKey(5), - userFromTokenAccount = testKey(6), toTokenAccount = testKey(7), - feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9), - fromMint = testKey(10), toMint = testKey(11), - user = testKey(12), whitelist = testKey(13), - amountIn = 1000L, minAmountOut = 900L, - ) - val accounts = ix.instruction().accounts - - // Writable: inVaultTokenAccount(3), outVaultTokenAccount(4), userFromTokenAccount(5), - // toTokenAccount(6), feeRecipientTokenAccount(7), user(11) - val writableIndices = setOf(3, 4, 5, 6, 7, 11) - for (i in accounts.indices) { - if (i in writableIndices) { - assertTrue(accounts[i].isWritable, "account at index $i should be writable") - } else { - assertFalse(accounts[i].isWritable, "account at index $i should not be writable") - } - } - } - // --- SwapV2 --- - private val goldenKeys = (0..12).map { i -> + private val goldenKeys = (0..11).map { i -> PublicKey(ByteArray(32) { if (it == 0) (i + 1).toByte() else 0 }.toList()) } - private fun goldenV1() = CoinbaseStableSwapperProgram_Swap( - pool = goldenKeys[0], inVault = goldenKeys[1], outVault = goldenKeys[2], - inVaultTokenAccount = goldenKeys[3], outVaultTokenAccount = goldenKeys[4], - userFromTokenAccount = goldenKeys[5], toTokenAccount = goldenKeys[6], - feeRecipientTokenAccount = goldenKeys[7], feeRecipient = goldenKeys[8], - fromMint = goldenKeys[9], toMint = goldenKeys[10], - user = goldenKeys[11], whitelist = goldenKeys[12], - amountIn = 1_500_000L, minAmountOut = 1_490_000L, - ).instruction() - private fun goldenV2() = CoinbaseStableSwapperProgram_SwapV2( pool = goldenKeys[0], inVault = goldenKeys[1], outVault = goldenKeys[2], inVaultTokenAccount = goldenKeys[3], outVaultTokenAccount = goldenKeys[4], @@ -240,19 +49,10 @@ class CoinbaseStableSwapperProgramTest { } @Test - fun swapV2ArgsMatchV1() { - assertEquals(goldenV1().data.drop(8), goldenV2().data.drop(8)) - assertTrue(goldenV1().data.take(8) != goldenV2().data.take(8)) - } - - @Test - fun swapV2AccountsAreV1WithoutWhitelist() { - val v1 = goldenV1() + fun swapV2AccountsHaveNoWhitelist() { val v2 = goldenV2() - assertEquals(16, v1.accounts.size) - assertEquals(goldenKeys[12], v1.accounts[12].publicKey) assertEquals(15, v2.accounts.size) - assertEquals(v1.accounts.filterIndexed { i, _ -> i != 12 }, v2.accounts) + assertEquals(goldenKeys.take(12), v2.accounts.take(12).map { it.publicKey }) assertEquals(CoinbaseStableSwapperProgram.address, v2.program) } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt index 5266e7408a..adece301b2 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructionsTest.kt @@ -3,7 +3,6 @@ package com.getcode.opencode.solana.swap import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseTokenVaultAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseVaultTokenAccountAddress -import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseWhitelistAddress import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram @@ -89,7 +88,6 @@ class CoinbaseStablecoinSwapperInstructionsTest { amount: Long = 1_000_000L, feeAmount: Long = 50_000L, minOutput: Long = 990_000L, - swapVersion: Int = 2, ) = buildStablecoinSwapperInstructions( serverParameters = serverParameters, nonce = nonce, @@ -101,7 +99,6 @@ class CoinbaseStablecoinSwapperInstructionsTest { amount = amount, feeAmount = feeAmount, minOutput = minOutput, - swapVersion = swapVersion, ) // --- Instruction count --- @@ -257,21 +254,11 @@ class CoinbaseStablecoinSwapperInstructionsTest { } @Test - fun `swap instruction is SwapV2 without whitelist by default`() { + fun `swap instruction is SwapV2 with 15 accounts`() { val ix = buildInstructions()[7] assertEquals(15, ix.accounts.size) assertEquals(CoinbaseStableSwapperProgram.SERVER_SWAP_VERSION, 2) assertEquals(listOf(43, 4, -19, 11, 26, -55, 30, 98), ix.data.take(8)) - val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey - assertTrue(ix.accounts.none { it.publicKey == whitelist }) - } - - @Test - fun `swap instruction has whitelist PDA when swapVersion is 1`() { - val ix = buildInstructions(swapVersion = 1)[7] - val expectedWhitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey - assertEquals(16, ix.accounts.size) - assertEquals(expectedWhitelist, ix.accounts[12].publicKey) } // --- CloseAccount verification --- diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt index 6f8f94db53..eaa471c7ac 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt @@ -4,7 +4,6 @@ import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseTokenVaultAddress import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseVaultTokenAccountAddress -import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseWhitelistAddress import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram @@ -92,14 +91,12 @@ class StatelessSwapInstructionsTest { private fun buildInstructions( amount: Long = 1_000_000L, params: StatelessSwapServerParameters = serverParameters, - swapVersion: Int = 2, ) = buildStatelessSwapInstructions( serverParameters = params, owner = owner, fromMint = fromMintMetadata, toMint = toMintMetadata, amount = amount, - swapVersion = swapVersion, ) // --- Instruction count --- @@ -299,20 +296,10 @@ class StatelessSwapInstructionsTest { } @Test - fun `swap instruction is SwapV2 without whitelist by default`() { + fun `swap instruction is SwapV2 with 15 accounts`() { val ix = buildInstructions()[4] assertEquals(15, ix.accounts.size) assertEquals(listOf(43, 4, -19, 11, 26, -55, 30, 98), ix.data.take(8)) - val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey - assertTrue(ix.accounts.none { it.publicKey == whitelist }) - } - - @Test - fun `swap instruction has whitelist PDA when swapVersion is 1`() { - val ix = buildInstructions(swapVersion = 1)[4] - val expectedWhitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey - assertEquals(16, ix.accounts.size) - assertEquals(expectedWhitelist, ix.accounts[12].publicKey) } // --- CreateIdempotent and Swap destination linkage --- From 1af104ee086bceccba658ca9c9059f7de4da9ff0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 15:22:16 -0400 Subject: [PATCH 5/6] docs(opencode): drop stale v1 whitelist references from swap comments --- .../solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt | 4 ++-- .../solana/swap/CoinbaseStablecoinDepositTransactionTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt index b3957b88be..0f0c706c79 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/programs/CoinbaseStableSwapperProgram_SwapV2.kt @@ -6,8 +6,8 @@ import com.getcode.solana.keys.PublicKey import com.getcode.utils.bytes /** - * CoinbaseStableSwapper::SwapV2. Same arguments as [CoinbaseStableSwapperProgram_Swap], but the - * deprecated whitelist account is gone (15 accounts instead of 16). + * CoinbaseStableSwapper::SwapV2 (`swap_v2`), the only swap instruction the app builds. It takes + * 15 accounts; the retired v1 `swap` also took a whitelist account. */ internal class CoinbaseStableSwapperProgram_SwapV2( private val pool: PublicKey, diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt index 554093ef30..55e3675b9a 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt @@ -20,7 +20,7 @@ import kotlin.test.assertTrue * valid-looking [PublicKey] and a transaction that encodes fine, so only the end-to-end bytes * catch it. * - * The deposit swap is `swap_v2` (15 accounts, no whitelist). The expected bytes are + * The deposit swap is `swap_v2` (15 accounts). The expected bytes are * builder-derived: there is no on-chain `swap_v2` capture for this transaction yet. The previous * `swap` (v1) version of these bytes was accepted by `simulateTransaction` on mainnet (no error, * 64422 compute units, `Swapped 5000000 tokens`). Two earlier shapes of that transaction were From 7dacdccc633b0f73c635805ee2f1064270364260 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 22 Oct 2026 15:33:05 -0400 Subject: [PATCH 6/6] docs(opencode): record the mainnet simulation of the swap_v2 deposit vector --- .../CoinbaseStablecoinDepositTransactionTest.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt index 55e3675b9a..1098edf749 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinDepositTransactionTest.kt @@ -20,13 +20,11 @@ import kotlin.test.assertTrue * valid-looking [PublicKey] and a transaction that encodes fine, so only the end-to-end bytes * catch it. * - * The deposit swap is `swap_v2` (15 accounts). The expected bytes are - * builder-derived: there is no on-chain `swap_v2` capture for this transaction yet. The previous - * `swap` (v1) version of these bytes was accepted by `simulateTransaction` on mainnet (no error, - * 64422 compute units, `Swapped 5000000 tokens`). Two earlier shapes of that transaction were - * rejected there: `fee_recipient` read at the offset the published IDL implies fails with - * `ConstraintAddress` (2012), and `minAmountOut = 0` fails with `InvalidAmount` (6002). - * Re-simulate the v2 bytes before relying on them as a mainnet-accepted vector. + * The deposit swap is `swap_v2` (15 accounts). These bytes were accepted by + * `simulateTransaction` on mainnet (no error, 59730 compute units, `Swapped 5000000 tokens`). + * Two earlier shapes of this transaction were rejected there: `fee_recipient` read at the offset + * the published IDL implies fails with `ConstraintAddress` (2012), and `minAmountOut = 0` fails + * with `InvalidAmount` (6002). */ class CoinbaseStablecoinDepositTransactionTest { @@ -50,7 +48,7 @@ class CoinbaseStablecoinDepositTransactionTest { private val amount = 5_000_000L /** - * Builder-derived `swap_v2` bytes; see the class comment. Any change here is a wire-format + * Mainnet-simulated `swap_v2` bytes; see the class comment. Any change here is a wire-format * change on the deposit path. Re-simulate before updating. */ private val expectedTransactionBase64 =