Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,56 @@ import com.getcode.solana.keys.PublicKey
/**
* Represents the on-chain CoinbaseStableSwapper liquidity pool account.
*
* Layout:
* [8 discriminator][32 operations_authority][32 pause_authority][32 fee_recipient]...
* Mirrors `LiquidityPool` in coinbase/stable-swapper (`programs/stable-swapper/src/state.rs`)
* after the role-based authority migration (coinbase/stable-swapper#20). Layout:
*
* ```
* [8 discriminator]
* [32 pause_authority][32 unpause_authority][32 treasury_authority][32 configure_authority]
* [32 fee_recipient]
* [4 + 32n withdraw_recipients][4 + 32n supported_tokens]
* [8 fee_rate][1 swaps_paused][1 liquidity_paused][1 bump]
* ```
*
* Only the fixed-offset prefix is parsed; the client needs the fee recipient and nothing
* after the vectors.
*/
internal data class CoinbaseStablecoinPoolAccount(
val pauseAuthority: PublicKey,
val unpauseAuthority: PublicKey,
val treasuryAuthority: PublicKey,
val configureAuthority: PublicKey,
val feeRecipient: PublicKey,
) {
companion object {
private const val FEE_RECIPIENT_OFFSET = 8 + 32 + 32 // discriminator + ops_authority + pause_authority
// LiquidityPool discriminator: [66, 38, 17, 64, 188, 80, 68, 129]
private val DISCRIMINATOR = byteArrayOf(66, 38, 17, 64, 188.toByte(), 80, 68, 129.toByte())

private const val KEY_SIZE = 32
private const val PAUSE_AUTHORITY_OFFSET = 8
private const val UNPAUSE_AUTHORITY_OFFSET = PAUSE_AUTHORITY_OFFSET + KEY_SIZE
private const val TREASURY_AUTHORITY_OFFSET = UNPAUSE_AUTHORITY_OFFSET + KEY_SIZE
private const val CONFIGURE_AUTHORITY_OFFSET = TREASURY_AUTHORITY_OFFSET + KEY_SIZE
private const val FEE_RECIPIENT_OFFSET = CONFIGURE_AUTHORITY_OFFSET + KEY_SIZE
private const val FIXED_PREFIX_SIZE = FEE_RECIPIENT_OFFSET + KEY_SIZE

fun fromAccountData(data: ByteArray): CoinbaseStablecoinPoolAccount {
require(data.size >= FEE_RECIPIENT_OFFSET + 32) {
"Account data too short: expected at least ${FEE_RECIPIENT_OFFSET + 32} bytes, got ${data.size}"
require(data.size >= FIXED_PREFIX_SIZE) {
"Account data too short: expected at least $FIXED_PREFIX_SIZE bytes, got ${data.size}"
}
require(data.sliceArray(0 until DISCRIMINATOR.size).contentEquals(DISCRIMINATOR)) {
"Account data is not a CoinbaseStableSwapper LiquidityPool (discriminator mismatch)"
}
val feeRecipientBytes = data.sliceArray(FEE_RECIPIENT_OFFSET until FEE_RECIPIENT_OFFSET + 32)
return CoinbaseStablecoinPoolAccount(
feeRecipient = PublicKey(feeRecipientBytes.toList()),
pauseAuthority = data.keyAt(PAUSE_AUTHORITY_OFFSET),
unpauseAuthority = data.keyAt(UNPAUSE_AUTHORITY_OFFSET),
treasuryAuthority = data.keyAt(TREASURY_AUTHORITY_OFFSET),
configureAuthority = data.keyAt(CONFIGURE_AUTHORITY_OFFSET),
feeRecipient = data.keyAt(FEE_RECIPIENT_OFFSET),
)
}

private fun ByteArray.keyAt(offset: Int): PublicKey =
PublicKey(sliceArray(offset until offset + KEY_SIZE).toList())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -29,15 +27,13 @@ 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,
inVault = inVault,
outVault = outVault,
inVaultTokenAccount = inVaultTokenAccount,
outVaultTokenAccount = outVaultTokenAccount,
whitelist = whitelist,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ internal class CoinbaseStableSwapperProgram_Swap(
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 {
Expand All @@ -38,7 +37,6 @@ internal class CoinbaseStableSwapperProgram_Swap(
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ internal fun buildStablecoinSwapperInstructions(
fromMint = fromMintMetadata.address,
toMint = toMintMetadata.address,
user = swapAuthority,
whitelist = swapAccounts.whitelist,
amountIn = amount,
minAmountOut = minOutput,
).instruction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ internal fun buildStatelessSwapInstructions(
fromMint = fromMint.address,
toMint = toMint.address,
user = owner,
whitelist = swapAccounts.whitelist,
amountIn = amount,
minAmountOut = amount, // 1:1 stable pair
).instruction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ internal fun buildUsdcToUsdfSwapInstructions(
fromMint = Mint.usdc,
toMint = Mint.usdf,
user = sender,
whitelist = swapAccounts.whitelist,
amountIn = amount,
minAmountOut = 0,
).instruction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.getcode.opencode.internal.solana.model

import com.getcode.solana.keys.base58
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

@OptIn(ExperimentalEncodingApi::class)
class CoinbaseStablecoinPoolAccountTest {

// First 315 bytes of mainnet pool CrDL9SoCyW1tBgn8k7rgGSpWhnszneWDbvKvqPAU4PL9
// at slot ~445672210, after the 2026-09-08 MigrateAuthorities instruction. The
// live account is 2107 bytes with the remainder zeroed. Same fixture as ocp-server.
private val mainnetPrefix = "QiYRQLxQRIEFHqE9vluQFKO1wbEwnd22aRe9qGrV03SIsz1AV7GqT/yEzcR/f+ALaKG4KMxbBfZ5dTNFPqxxZHMfbTqNfj6St0p+" +
"+yObz2IILMcKsoko07O+oRSDek7YwzrH9TroL1+QbHdT/t9qpcq4Kyx8OPWZm79AIUM9UlN+X6ujF0hPgzT4z8SXtrVTfBhZj7Lz" +
"SPTgCpHoi6cjfPqXvflOJvRBAgAAAN0H70q0C5DeChX575Umuo4KwnYx+lqZmPTGnq1wMK2QSoyv1lJlvQkMgeq0VkN3NML2MHaz" +
"cTzSusODf5c6RhACAAAAxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWE908SAij1Ps5+uycukm1pjSlLt4RVI9SSqIPLN" +
"Ru+AcQAAAAAAAAAAAAD/"

private fun mainnetData(): ByteArray {
val prefix = Base64.decode(mainnetPrefix)
assertEquals(315, prefix.size)
return prefix.copyOf(2107)
}

@Test
fun `parses mainnet pool account after authority migration`() {
val pool = CoinbaseStablecoinPoolAccount.fromAccountData(mainnetData())

assertEquals("Lz8QXHjETKQnt1fzsKbN4AyQEhhVAFB2YKwAMqksr7G", pool.pauseAuthority.base58())
assertEquals("HzjC9U1WifkqLhYMx592UG1fJqX3BtRjCqPPPTo3hA8R", pool.unpauseAuthority.base58())
assertEquals("DLVVcd3xfwWeCwGz1EUQbqaNC88NooN6s9ifWo87QZst", pool.treasuryAuthority.base58())
assertEquals("Aimdv5hcHfm2PKuGwDW9H81iibZoYKLv3TPMEaZhmvqG", pool.configureAuthority.base58())
assertEquals("4ZnFXk7KyB5khDqjWSHqHBQH1nQCnmvkr1pRFivWcP7e", pool.feeRecipient.base58())
}

@Test
fun `fee recipient is read at the post-migration offset`() {
// Pre-migration layout put fee_recipient at byte 72; reading there now yields
// treasury_authority, which would send pool fees to the wrong account.
val pool = CoinbaseStablecoinPoolAccount.fromAccountData(mainnetData())
assertEquals(pool.treasuryAuthority, keyAt(mainnetData(), 72))
assertEquals(pool.feeRecipient, keyAt(mainnetData(), 136))
}

@Test
fun `rejects truncated data`() {
val data = mainnetData()
for (size in listOf(0, 8, 135, 167)) {
assertFailsWith<IllegalArgumentException>("size $size") {
CoinbaseStablecoinPoolAccount.fromAccountData(data.copyOf(size))
}
}
// Exactly the fixed prefix is enough.
CoinbaseStablecoinPoolAccount.fromAccountData(data.copyOf(168))
}

@Test
fun `rejects wrong discriminator`() {
val data = mainnetData()
// TokenVault discriminator: [121, 7, 84, 254, 151, 228, 43, 144]
byteArrayOf(121, 7, 84, 254.toByte(), 151.toByte(), 228.toByte(), 43, 144.toByte())
.copyInto(data)
assertFailsWith<IllegalArgumentException> {
CoinbaseStablecoinPoolAccount.fromAccountData(data)
}
}

private fun keyAt(data: ByteArray, offset: Int) =
com.getcode.solana.keys.PublicKey(data.sliceArray(offset until offset + 32).toList())
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 0L, minAmountOut = 0L,
)
val encoded = ix.encode()
Expand All @@ -57,7 +57,7 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 1000L, minAmountOut = 900L,
)
// 8 bytes discriminator + 8 bytes amountIn + 8 bytes minAmountOut = 24
Expand All @@ -72,7 +72,7 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 1_000_000L, minAmountOut = 999_000L,
)
val encoded = ix.encode()
Expand All @@ -93,17 +93,17 @@ class CoinbaseStableSwapperProgramTest {
// --- Swap instruction accounts ---

@Test
fun swapInstructionHas16Accounts() {
fun swapInstructionHas15Accounts() {
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),
user = testKey(12),
amountIn = 1000L, minAmountOut = 900L,
)
assertEquals(16, ix.instruction().accounts.size)
assertEquals(15, ix.instruction().accounts.size)
}

@Test
Expand All @@ -114,28 +114,28 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 1000L, minAmountOut = 900L,
)
assertEquals(CoinbaseStableSwapperProgram.address, ix.instruction().program)
}

@Test
fun swapAccountOrder() {
val keys = (1..13).map { testKey(it) }
val keys = (1..12).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],
user = keys[11],
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,
// userFromTA, toTA, feeRecipientTA, feeRecipient, fromMint, toMint, user,
// tokenProgram, associatedTokenProgram, systemProgram
assertEquals(keys[0], accounts[0].publicKey) // pool
assertEquals(keys[1], accounts[1].publicKey) // inVault
Expand All @@ -149,10 +149,9 @@ class CoinbaseStableSwapperProgramTest {
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)
assertEquals(TokenProgram.address, accounts[12].publicKey)
assertEquals(AssociatedTokenProgram.address, accounts[13].publicKey)
assertEquals(SystemProgram.address, accounts[14].publicKey)
}

@Test
Expand All @@ -163,7 +162,7 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 1000L, minAmountOut = 900L,
)
val accounts = ix.instruction().accounts
Expand All @@ -186,7 +185,7 @@ class CoinbaseStableSwapperProgramTest {
userFromTokenAccount = testKey(6), toTokenAccount = testKey(7),
feeRecipientTokenAccount = testKey(8), feeRecipient = testKey(9),
fromMint = testKey(10), toMint = testKey(11),
user = testKey(12), whitelist = testKey(13),
user = testKey(12),
amountIn = 1000L, minAmountOut = 900L,
)
val accounts = ix.instruction().accounts
Expand Down
Loading
Loading