From b34e5fd5d4d756c567683ac4097e35c680e7d59e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 19:46:46 -0400 Subject: [PATCH] fix(swap): follow the Coinbase Stable Swapper authority migration The 2026-09-08 MigrateAuthorities instruction on the mainnet pool replaced `operations_authority` with pause, unpause, treasury, and configure authorities, which moves `fee_recipient` from byte 72 to byte 136. `PoolAccount` still read byte 72, so the wallet-connection deposit flow was handing the old `treasury_authority` slot to the swap as the fee recipient. The same program upgrade dropped the `address_whitelist` account from `swap`, so the instruction now carries 15 accounts with the three programs following the user. This mirrors ocp-server#257. The pool-account suite gains a fixture built from the migrated mainnet pool bytes and asserts the parsed fee recipient. The swap builder tests replace their whitelist assertions with checks on accounts 12 to 14 and the count. The server's compute-unit-limit bump to 200k needs no client change: the stateless and USDF to USDC builders take the limit from server parameters, and the USDC to USDF builder already sets 200k. --- .../CoinbaseStableSwapperProgram+PDAs.swift | 15 ++------ ...baseStableSwapperProgram.PoolAccount.swift | 8 +++-- .../CoinbaseStableSwapperProgram.Swap.swift | 13 +++---- ...SwapInstructionBuilder+StatelessSwap.swift | 1 - .../SwapInstructionBuilder+UsdcToUsdf.swift | 1 - .../SwapInstructionBuilder+UsdfToUsdc.swift | 1 - ...oinbaseStableSwapperPoolAccountTests.swift | 36 +++++++++++++++---- ...wapInstructionBuilderUsdcToUsdfTests.swift | 8 +++-- ...wapInstructionBuilderUsdfToUsdcTests.swift | 10 +++--- .../WalletConnectionPoolResolutionTests.swift | 3 +- 10 files changed, 54 insertions(+), 42 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift index f5565b6bd..94029c6dd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift @@ -38,14 +38,6 @@ extension CoinbaseStableSwapperProgram { ) } - /// PDA: ["address_whitelist"] @ CoinbaseStableSwapperProgram - public static func deriveWhitelistAddress() -> ProgramDerivedAccount? { - ProgramDerivedAccount.findProgramAddress( - seeds: [Data("address_whitelist".utf8)], - program: address - ) - } - /// The pool-side accounts a `Swap` instruction targets for a mint pair. public struct SwapAccounts: Equatable, Sendable { public let pool: PublicKey @@ -53,7 +45,6 @@ extension CoinbaseStableSwapperProgram { public let outVault: PublicKey public let inVaultTokenAccount: PublicKey public let outVaultTokenAccount: PublicKey - public let whitelist: PublicKey } /// Derives every pool-side account `Swap` needs for the given mint pair, @@ -67,8 +58,7 @@ extension CoinbaseStableSwapperProgram { let inVault = deriveTokenVaultAddress(pool: pool.publicKey, mint: fromMint), let outVault = deriveTokenVaultAddress(pool: pool.publicKey, mint: toMint), let inVaultTokenAccount = deriveVaultTokenAccountAddress(vault: inVault.publicKey), - let outVaultTokenAccount = deriveVaultTokenAccountAddress(vault: outVault.publicKey), - let whitelist = deriveWhitelistAddress() + let outVaultTokenAccount = deriveVaultTokenAccountAddress(vault: outVault.publicKey) else { return nil } @@ -78,8 +68,7 @@ extension CoinbaseStableSwapperProgram { inVault: inVault.publicKey, outVault: outVault.publicKey, inVaultTokenAccount: inVaultTokenAccount.publicKey, - outVaultTokenAccount: outVaultTokenAccount.publicKey, - whitelist: whitelist.publicKey + outVaultTokenAccount: outVaultTokenAccount.publicKey ) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.PoolAccount.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.PoolAccount.swift index 0a39ed526..1a52b18a1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.PoolAccount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.PoolAccount.swift @@ -7,14 +7,16 @@ import Foundation extension CoinbaseStableSwapperProgram { - /// The on-chain liquidity pool account. + /// The on-chain liquidity pool account, as laid out after the role-based + /// authority migration (coinbase/stable-swapper#20). /// - /// Layout: `[8 discriminator][32 operations_authority][32 pause_authority][32 fee_recipient]...` + /// Layout: `[8 discriminator][32 pause_authority][32 unpause_authority] + /// [32 treasury_authority][32 configure_authority][32 fee_recipient]...` public struct PoolAccount: Equatable, Sendable { public let feeRecipient: PublicKey - private static let feeRecipientOffset = 8 + 32 + 32 + private static let feeRecipientOffset = 8 + 32 * 4 /// Parses the raw pool account data, returning `nil` when the data is /// too short or the fee recipient bytes are not a valid public key. diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift index b8591987c..63e5d8ef3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift @@ -9,7 +9,7 @@ extension CoinbaseStableSwapperProgram { /// Anchor-format instruction for the Coinbase Stable Swapper's `swap` handler. /// - /// Account list (16, in this exact order): + /// Account list (15, in this exact order): /// 0. [] pool /// 1. [] inVault /// 2. [] outVault @@ -22,10 +22,9 @@ extension CoinbaseStableSwapperProgram { /// 9. [] fromMint /// 10. [] toMint /// 11. [WRITE, SIGNER] user - /// 12. [] whitelist - /// 13. [] TokenProgram - /// 14. [] AssociatedTokenProgram - /// 15. [] SystemProgram + /// 12. [] TokenProgram + /// 13. [] AssociatedTokenProgram + /// 14. [] SystemProgram /// public struct Swap { @@ -41,7 +40,6 @@ extension CoinbaseStableSwapperProgram { public let fromMint: PublicKey public let toMint: PublicKey public let user: PublicKey - public let whitelist: PublicKey public let amountIn: UInt64 public let minAmountOut: UInt64 @@ -61,7 +59,6 @@ extension CoinbaseStableSwapperProgram { fromMint: PublicKey, toMint: PublicKey, user: PublicKey, - whitelist: PublicKey, amountIn: UInt64, minAmountOut: UInt64 ) { @@ -77,7 +74,6 @@ extension CoinbaseStableSwapperProgram { self.fromMint = fromMint self.toMint = toMint self.user = user - self.whitelist = whitelist self.amountIn = amountIn self.minAmountOut = minAmountOut } @@ -108,7 +104,6 @@ extension CoinbaseStableSwapperProgram.Swap: InstructionType { .readonly(publicKey: fromMint), .readonly(publicKey: toMint), .writable(publicKey: user, signer: true), - .readonly(publicKey: whitelist), .readonly(publicKey: TokenProgram.address), .readonly(publicKey: AssociatedTokenProgram.address), .readonly(publicKey: SystemProgram.address), diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift index c20c1bdee..f6b970f56 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift @@ -141,7 +141,6 @@ extension SwapInstructionBuilder { fromMint: fromMint.address, toMint: toMint.address, user: owner, - whitelist: swapAccounts.whitelist, amountIn: amount, minAmountOut: amount ).instruction() diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift index 820ccbcc9..20d1fa29e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift @@ -164,7 +164,6 @@ extension SwapInstructionBuilder { fromMint: .usdc, toMint: .usdf, user: sender, - whitelist: swapAccounts.whitelist, amountIn: amount, minAmountOut: amount ).instruction() diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift index 11adfbcdc..046caf110 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift @@ -141,7 +141,6 @@ extension SwapInstructionBuilder { fromMint: fromMintMetadata.address, toMint: toMintMetadata.address, user: swapAuthority, - whitelist: swapAccounts.whitelist, amountIn: amount, minAmountOut: minOutput ).instruction() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperPoolAccountTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperPoolAccountTests.swift index 9f0ca6c73..1a1af4def 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperPoolAccountTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperPoolAccountTests.swift @@ -5,21 +5,34 @@ import Testing @Suite("CoinbaseStableSwapperProgram.PoolAccount") struct CoinbaseStableSwapperPoolAccountTests { - /// Minimum account length: 8 discriminator + 32 ops authority - /// + 32 pause authority + 32 fee recipient. - private static let minimumLength = 8 + 32 + 32 + 32 + /// Minimum account length: 8 discriminator + 32 pause authority + /// + 32 unpause authority + 32 treasury authority + 32 configure authority + /// + 32 fee recipient. + private static let minimumLength = 8 + 32 * 5 + + /// First 315 bytes of mainnet pool CrDL9SoCyW1tBgn8k7rgGSpWhnszneWDbvKvqPAU4PL9 + /// after the 2026-09-08 MigrateAuthorities instruction. The live account is + /// 2107 bytes with the remainder zeroed. + private static let mainnetPoolPrefix = + "QiYRQLxQRIEFHqE9vluQFKO1wbEwnd22aRe9qGrV03SIsz1AV7GqT/yEzcR/f+ALaKG4KMxbBfZ5dTNFPqxxZHMfbTqNfj6St0p+" + + "+yObz2IILMcKsoko07O+oRSDek7YwzrH9TroL1+QbHdT/t9qpcq4Kyx8OPWZm79AIUM9UlN+X6ujF0hPgzT4z8SXtrVTfBhZj7Lz" + + "SPTgCpHoi6cjfPqXvflOJvRBAgAAAN0H70q0C5DeChX575Umuo4KwnYx+lqZmPTGnq1wMK2QSoyv1lJlvQkMgeq0VkN3NML2MHaz" + + "cTzSusODf5c6RhACAAAAxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWE908SAij1Ps5+uycukm1pjSlLt4RVI9SSqIPLN" + + "Ru+AcQAAAAAAAAAAAAD/" private static func accountData(feeRecipient: [UInt8], trailing: Int = 0) -> Data { var data = Data(repeating: 0xAA, count: 8) // discriminator - data.append(Data(repeating: 0xBB, count: 32)) // operations authority - data.append(Data(repeating: 0xCC, count: 32)) // pause authority + data.append(Data(repeating: 0xBB, count: 32)) // pause authority + data.append(Data(repeating: 0xCC, count: 32)) // unpause authority + data.append(Data(repeating: 0xEE, count: 32)) // treasury authority + data.append(Data(repeating: 0x11, count: 32)) // configure authority data.append(Data(feeRecipient)) data.append(Data(repeating: 0xDD, count: trailing)) return data } @Test( - "Parses the fee recipient at offset 72, with or without trailing fields", + "Parses the fee recipient at offset 136, with or without trailing fields", arguments: [0, 128] ) func initAccountData_validLayout_parsesFeeRecipient(trailing: Int) throws { @@ -32,6 +45,17 @@ struct CoinbaseStableSwapperPoolAccountTests { #expect(account.feeRecipient == (try PublicKey(feeRecipientBytes))) } + @Test("Parses the fee recipient from the migrated mainnet pool account") + func initAccountData_mainnetPool_parsesFeeRecipient() throws { + let prefix = try #require(Data(base64Encoded: Self.mainnetPoolPrefix)) + #expect(prefix.count == 315) + var data = prefix + data.append(Data(repeating: 0, count: 2107 - prefix.count)) + + let account = try #require(CoinbaseStableSwapperProgram.PoolAccount(accountData: data)) + #expect(account.feeRecipient == (try PublicKey(base58: "4ZnFXk7KyB5khDqjWSHqHBQH1nQCnmvkr1pRFivWcP7e"))) + } + @Test("Rejects account data shorter than the fee recipient bounds") func initAccountData_shortData_returnsNil() { let short = Data(repeating: 0xAA, count: Self.minimumLength - 1) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift index 60f3acb51..3ff39ee6b 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift @@ -262,7 +262,7 @@ struct SwapInstructionBuilderUsdcToUsdfTests { #expect(coinbase.data == legacy.data) } - @Test("Coinbase swap accounts: pool PDAs, sender ATAs, fee recipient, whitelist") + @Test("Coinbase swap accounts: pool PDAs, sender ATAs, fee recipient, programs") func coinbase_swapAccounts() throws { let instructions = Self.makeCoinbaseInstructions() let ix = instructions[6] @@ -272,7 +272,6 @@ struct SwapInstructionBuilderUsdcToUsdfTests { let outVault = try #require(CoinbaseStableSwapperProgram.deriveTokenVaultAddress(pool: pool, mint: .usdf)).publicKey let inVaultTokenAccount = try #require(CoinbaseStableSwapperProgram.deriveVaultTokenAccountAddress(vault: inVault)).publicKey let outVaultTokenAccount = try #require(CoinbaseStableSwapperProgram.deriveVaultTokenAccountAddress(vault: outVault)).publicKey - let whitelist = try #require(CoinbaseStableSwapperProgram.deriveWhitelistAddress()).publicKey let senderUsdcAta = instructions[4].accounts[1].publicKey let senderUsdfAta = instructions[2].accounts[1].publicKey let feeRecipientUsdcAta = try #require( @@ -293,7 +292,10 @@ struct SwapInstructionBuilderUsdcToUsdfTests { #expect(ix.accounts[10].publicKey == PublicKey.usdf) #expect(ix.accounts[11].publicKey == Self.sender) #expect(ix.accounts[11].isSigner) - #expect(ix.accounts[12].publicKey == whitelist) + #expect(ix.accounts[12].publicKey == TokenProgram.address) + #expect(ix.accounts[13].publicKey == AssociatedTokenProgram.address) + #expect(ix.accounts[14].publicKey == SystemProgram.address) + #expect(ix.accounts.count == 15) } @Test("Coinbase swap data: discriminator + amountIn(8 LE) + minAmountOut(8 LE), both equal to amount") diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift index 33ecce970..26bee4b7a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift @@ -248,11 +248,13 @@ struct SwapInstructionBuilderUsdfToUsdcTests { #expect(ix.accounts[11].isSigner == true) } - @Test("Swap whitelist (account 12) is the Coinbase whitelist PDA") - func swap_whitelist() { + @Test("Swap programs (accounts 12-14) follow the user directly; there is no whitelist account") + func swap_programs() { let ix = Self.makeInstructions()[7] - let expected = CoinbaseStableSwapperProgram.deriveWhitelistAddress()!.publicKey - #expect(ix.accounts[12].publicKey == expected) + #expect(ix.accounts[12].publicKey == TokenProgram.address) + #expect(ix.accounts[13].publicKey == AssociatedTokenProgram.address) + #expect(ix.accounts[14].publicKey == SystemProgram.address) + #expect(ix.accounts.count == 15) } // MARK: - Discriminator + data layout (24 bytes) diff --git a/FlipcashTests/WalletConnectionPoolResolutionTests.swift b/FlipcashTests/WalletConnectionPoolResolutionTests.swift index b646f29ea..88befc78f 100644 --- a/FlipcashTests/WalletConnectionPoolResolutionTests.swift +++ b/FlipcashTests/WalletConnectionPoolResolutionTests.swift @@ -13,7 +13,8 @@ import FlipcashCore struct WalletConnectionPoolResolutionTests { private nonisolated static func poolAccountData(feeRecipient: [UInt8]) -> Data { - var data = Data(repeating: 0, count: 8 + 32 + 32) + // 8 discriminator + pause, unpause, treasury, configure authorities. + var data = Data(repeating: 0, count: 8 + 32 * 4) data.append(Data(feeRecipient)) return data }