diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 02241c907..f19253844 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "967b87f053de503ec7757ad7d9e6ebc7bb6a3b87ae116109d6b2630591c572f0", + "originHash" : "c1a366f97e033b7ee60810d9f4229a153bddc573d336d834f2446e421324d519", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/ocp-client-protocol", "state" : { - "revision" : "01b4f913f076e03732d7aed0e19a689cce14ab07", - "version" : "0.8.0" + "revision" : "831b209e37c482143b8da2b0f850ed534755bf10", + "version" : "0.9.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 7912cf3b8..866a969a8 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -55,7 +55,7 @@ enum ContractPackage: String, CaseIterable { /// The pinned version consumed when this package isn't building against a local checkout. var version: Version { switch self { - case .ocp: return "0.8.0" + case .ocp: return "0.9.0" case .flipcash2: return "0.20.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService+StatelessSwap.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService+StatelessSwap.swift index 56eefbf50..e219a773a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService+StatelessSwap.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/SwapService+StatelessSwap.swift @@ -186,11 +186,7 @@ extension SwapService { // serialization time, per `SwiftProtobuf.Message.sign(with:)`). let initiate = Ocp_Transaction_V1_StatelessSwapRequest.with { $0.initiate = .with { - $0.kind = .stablecoin(.with { - $0.fromMint = fromMint.address.solanaAccountID - $0.toMint = toMint.address.solanaAccountID - $0.swapAmount = amount.quarks - }) + $0.kind = .stablecoin(.coinbaseStableSwapper(fromMint: fromMint, toMint: toMint, amount: amount)) $0.owner = owner.publicKey.solanaAccountID $0.waitForFinalization = true $0.signature = $0.sign(with: owner) @@ -201,6 +197,24 @@ extension SwapService { } } +extension Ocp_Transaction_V1_StatelessSwapRequest.Initiate.CoinbaseStableSwapperClientParameters { + + /// Initiate parameters for a stateless Coinbase swap, stamped with the version of the + /// instruction `TransactionBuilder.statelessSwap` builds so the two cannot drift. + static func coinbaseStableSwapper( + fromMint: MintMetadata, + toMint: MintMetadata, + amount: TokenAmount + ) -> Self { + .with { + $0.fromMint = fromMint.address.solanaAccountID + $0.toMint = toMint.address.solanaAccountID + $0.swapAmount = amount.quarks + $0.swapVersion = CoinbaseStableSwapperProgram.SwapV2.version + } + } +} + /// Wraps an async completion so it can only fire once. Used to funnel the /// response handler and the stream-status handler into the same `Result` /// without double-resuming the upstream continuation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift b/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift index deb8f6bf3..9648c07ab 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/SwapModels.swift @@ -297,6 +297,8 @@ extension VerifiedSwapMetadata.ClientParameters { $0.fundingSource = fundingSource.protoSource $0.fundingID = fundingSource.fundingID $0.destinationOwner = destinationOwner.solanaAccountID + // Must match the instruction `TransactionBuilder.swapUsdfToUsdc` builds. + $0.swapVersion = CoinbaseStableSwapperProgram.SwapV2.version } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift index f5565b6bd..d24c613d5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram+PDAs.swift @@ -38,25 +38,16 @@ 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. + /// The pool-side accounts a `SwapV2` instruction targets for a mint pair. public struct SwapAccounts: Equatable, Sendable { public let pool: PublicKey public let inVault: PublicKey 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, + /// Derives every pool-side account `SwapV2` needs for the given mint pair, /// or `nil` when any derivation fails. public static func deriveSwapAccounts( fromMint: PublicKey, @@ -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.Swap.swift b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.SwapV2.swift similarity index 84% rename from FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift rename to FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.SwapV2.swift index b8591987c..2a023ece8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.Swap.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.SwapV2.swift @@ -1,5 +1,5 @@ // -// CoinbaseStableSwapperProgram.Swap.swift +// CoinbaseStableSwapperProgram.SwapV2.swift // FlipcashCore // @@ -7,9 +7,11 @@ import Foundation extension CoinbaseStableSwapperProgram { - /// Anchor-format instruction for the Coinbase Stable Swapper's `swap` handler. + /// Anchor-format instruction for the Coinbase Stable Swapper's `swap_v2` handler. /// - /// Account list (16, in this exact order): + /// Takes no whitelist account (the retired v1 `swap` handler had one). + /// + /// Account list (15, in this exact order): /// 0. [] pool /// 1. [] inVault /// 2. [] outVault @@ -22,12 +24,14 @@ 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 { + public struct SwapV2 { + + /// The `swap_version` a request must carry so the server builds the same instruction. + public static let version: UInt32 = 2 public let pool: PublicKey public let inVault: PublicKey @@ -41,12 +45,11 @@ 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 - /// Anchor discriminator for `swap`: sha256("global:swap")[0..8] - public static let discriminator: [UInt8] = [248, 198, 158, 145, 225, 117, 135, 200] + /// Anchor discriminator for `swap_v2`: sha256("global:swap_v2")[0..8] + public static let discriminator: [UInt8] = [43, 4, 237, 11, 26, 201, 30, 98] public init( pool: PublicKey, @@ -61,7 +64,6 @@ extension CoinbaseStableSwapperProgram { fromMint: PublicKey, toMint: PublicKey, user: PublicKey, - whitelist: PublicKey, amountIn: UInt64, minAmountOut: UInt64 ) { @@ -77,7 +79,6 @@ extension CoinbaseStableSwapperProgram { self.fromMint = fromMint self.toMint = toMint self.user = user - self.whitelist = whitelist self.amountIn = amountIn self.minAmountOut = minAmountOut } @@ -86,7 +87,7 @@ extension CoinbaseStableSwapperProgram { // MARK: - InstructionType - -extension CoinbaseStableSwapperProgram.Swap: InstructionType { +extension CoinbaseStableSwapperProgram.SwapV2: InstructionType { public init(instruction: Instruction) throws { throw CommandParseError.instructionMismatch @@ -108,7 +109,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..30d494cc2 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+StatelessSwap.swift @@ -16,7 +16,7 @@ extension SwapInstructionBuilder { /// 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 @@ -122,13 +122,13 @@ extension SwapInstructionBuilder { ).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. // `minAmountOut: amount` enforces a 1:1 rate; the Coinbase Stable // Swapper is a stable pair so any slippage indicates a misconfigured // pool and the swap should fail server-side rather than silently // lose value. instructions.append( - CoinbaseStableSwapperProgram.Swap( + CoinbaseStableSwapperProgram.SwapV2( pool: swapAccounts.pool, inVault: swapAccounts.inVault, outVault: swapAccounts.outVault, @@ -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..4719cbc5f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdcToUsdf.swift @@ -115,7 +115,8 @@ extension SwapInstructionBuilder { MemoProgram.Memo(message: swapId.base58).instruction() ) - // 7. Swap (sender's USDC ATA → sender's USDF ATA) + // 7. Swap (sender's USDC ATA → sender's USDF ATA). The Coinbase pool uses + // `swap_v2`, which dropped the whitelist account. switch pool { case .usdf: instructions.append( @@ -151,7 +152,7 @@ extension SwapInstructionBuilder { // misconfigured pool and the swap should fail rather than // silently lose value. instructions.append( - CoinbaseStableSwapperProgram.Swap( + CoinbaseStableSwapperProgram.SwapV2( pool: swapAccounts.pool, inVault: swapAccounts.inVault, outVault: swapAccounts.outVault, @@ -164,7 +165,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..b9f7da720 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/SwapInstructionBuilder+UsdfToUsdc.swift @@ -125,10 +125,10 @@ extension SwapInstructionBuilder { ).instruction() ) - // 8. CoinbaseStableSwapper::Swap + // 8. CoinbaseStableSwapper::SwapV2 // Swaps USDF in the swap-authority's ATA for USDC into the destination-owner's ATA. instructions.append( - CoinbaseStableSwapperProgram.Swap( + CoinbaseStableSwapperProgram.SwapV2( pool: swapAccounts.pool, inVault: swapAccounts.inVault, outVault: swapAccounts.outVault, @@ -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/CoinbaseStableSwapperDepositTransactionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperDepositTransactionTests.swift index dd3b35d77..dd0b4b893 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperDepositTransactionTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperDepositTransactionTests.swift @@ -15,9 +15,10 @@ import Testing /// offset in `PoolAccount` yields a 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, 68922 compute -/// units, `Swapped 5000000 tokens`). Reading `fee_recipient` at the offset the published IDL -/// implies instead fails the same simulation with `ConstraintAddress` (2012). +/// The deposit swap is `swap_v2` (15 accounts). These bytes were accepted by +/// `simulateTransaction` on mainnet (no error, 34752 compute units, `Swapped 5000000 tokens`). +/// Reading `fee_recipient` at the offset the published IDL implies instead failed that +/// simulation with `ConstraintAddress` (2012). @Suite("CoinbaseStableSwapper deposit transaction bytes") struct CoinbaseStableSwapperDepositTransactionTests { @@ -38,8 +39,9 @@ struct CoinbaseStableSwapperDepositTransactionTests { private static let amount: UInt64 = 5_000_000 /// Any change here is a wire-format change on the deposit path. Re-simulate before updating. + /// Mainnet-simulated `swap_v2` bytes; see the suite comment. private static let expectedBase64 = """ - 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 """ // MARK: - Tests @@ -72,6 +74,8 @@ struct CoinbaseStableSwapperDepositTransactionTests { ) #expect(instructions.count == 8) + #expect(instructions[6].data.prefix(8).elementsEqual(CoinbaseStableSwapperProgram.SwapV2.discriminator)) + #expect(instructions[6].accounts.count == 15) #expect(transaction.encode() == (try #require(Data(base64Encoded: Self.expectedBase64)))) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperSwapV2Tests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperSwapV2Tests.swift new file mode 100644 index 000000000..8e1c99258 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperSwapV2Tests.swift @@ -0,0 +1,107 @@ +// +// CoinbaseStableSwapperSwapV2Tests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +import OCPClientProtocol +@testable import FlipcashCore + +/// Golden vector shared with Android and the server's `instructions_swap_v2_test.go`. +@Suite("CoinbaseStableSwapper SwapV2 instruction") +struct CoinbaseStableSwapperSwapV2Tests { + + private static func key(_ i: Int) -> PublicKey { + var bytes = [UInt8](repeating: 0, count: 32) + bytes[0] = UInt8(i + 1) + return try! PublicKey(bytes) + } + + private static let amountIn: UInt64 = 1_500_000 + private static let minAmountOut: UInt64 = 1_490_000 + + private static var v2: Instruction { + CoinbaseStableSwapperProgram.SwapV2( + pool: key(0), inVault: key(1), outVault: key(2), + inVaultTokenAccount: key(3), outVaultTokenAccount: key(4), + userFromTokenAccount: key(5), toTokenAccount: key(6), + feeRecipientTokenAccount: key(7), feeRecipient: key(8), + fromMint: key(9), toMint: key(10), user: key(11), + amountIn: amountIn, minAmountOut: minAmountOut + ).instruction() + } + + @Test("Data matches the golden vector exactly") + func data_goldenVector() { + let expected: [UInt8] = [ + 43, 4, 237, 11, 26, 201, 30, 98, + 0x60, 0xe3, 0x16, 0, 0, 0, 0, 0, + 0x50, 0xbc, 0x16, 0, 0, 0, 0, 0, + ] + #expect(Array(Self.v2.data) == expected) + } + + @Test("Accounts match the golden 15-account list, in order, with only the user signing") + func accounts_golden() { + let accounts = Self.v2.accounts + #expect(accounts.map(\.publicKey) == (0...11).map(Self.key) + [ + TokenProgram.address, AssociatedTokenProgram.address, SystemProgram.address, + ]) + #expect(accounts.map(\.isSigner) == (0..<15).map { $0 == 11 }) + #expect(accounts.map(\.isWritable) == (0..<15).map { [3, 4, 5, 6, 7, 11].contains($0) }) + } + + @Test("Targets the Coinbase Stable Swapper program") + func program() { + #expect(Self.v2.program == CoinbaseStableSwapperProgram.address) + } + + @Test("Stateful stablecoin client parameters carry swapVersion 2") + func statefulClientParameters_carryVersion() { + let params = VerifiedSwapMetadata.ClientParameters( + id: SwapId.generate(), + fromMint: MintMetadata.usdf.address, + toMint: MintMetadata.usdc.address, + amount: TokenAmount(quarks: 1_000_000, mint: MintMetadata.usdf.address), + fundingSource: .submitIntent(id: Self.key(0)), + kind: .stablecoin(destinationOwner: Self.key(1)) + ) + #expect(params.stablecoinProto.swapVersion == 2) + } + + @Test("Stateless stablecoin initiate parameters carry swapVersion 2") + func statelessInitiate_carriesVersion() { + let params = Ocp_Transaction_V1_StatelessSwapRequest.Initiate.CoinbaseStableSwapperClientParameters + .coinbaseStableSwapper( + fromMint: .usdc, + toMint: .usdf, + amount: TokenAmount(quarks: 1_000_000, mint: MintMetadata.usdc.address) + ) + #expect(params.swapVersion == 2) + } + + @Test("Stateless builder emits SwapV2 (15 accounts, v2 discriminator)") + func statelessBuilder_emitsV2() { + let serverParams = StatelessSwapServerParameters( + payer: Self.key(20), + blockhash: try! Hash([UInt8](repeating: 3, count: 32)), + alts: [], + computeUnitLimit: 0, + computeUnitPrice: 0, + memoValue: "", + poolFeeRecipient: Self.key(21) + ) + let ixs = SwapInstructionBuilder.buildStatelessSwapInstructions( + serverParameters: serverParams, + owner: Self.key(22), + fromMint: .usdc, + toMint: .usdf, + amount: 1_000_000 + ) + let swap = ixs.last! + #expect(swap.program == CoinbaseStableSwapperProgram.address) + #expect(swap.accounts.count == 15) + #expect(Array(swap.data.prefix(8)) == CoinbaseStableSwapperProgram.SwapV2.discriminator) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdcToUsdfTests.swift index 60f3acb51..763f4cd7c 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, no whitelist") func coinbase_swapAccounts() throws { let instructions = Self.makeCoinbaseInstructions() let ix = instructions[6] @@ -272,14 +272,13 @@ 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( PublicKey.deriveAssociatedAccount(from: Self.feeRecipient, mint: .usdc) ).publicKey - // Account list order documented on CoinbaseStableSwapperProgram.Swap. + // Account list order documented on CoinbaseStableSwapperProgram.SwapV2. #expect(ix.accounts[0].publicKey == pool) #expect(ix.accounts[1].publicKey == inVault) #expect(ix.accounts[2].publicKey == outVault) @@ -293,14 +292,14 @@ 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.count == 15) } @Test("Coinbase swap data: discriminator + amountIn(8 LE) + minAmountOut(8 LE), both equal to amount") func coinbase_dataLayout() { let ix = Self.makeCoinbaseInstructions(amount: 20_000_000)[6] #expect(ix.data.count == 8 + 8 + 8) - #expect(Array(ix.data.prefix(8)) == CoinbaseStableSwapperProgram.Swap.discriminator) + #expect(Array(ix.data.prefix(8)) == CoinbaseStableSwapperProgram.SwapV2.discriminator) #expect(UInt64(bytes: Array(ix.data[8..<16])) == 20_000_000) #expect(UInt64(bytes: Array(ix.data[16..<24])) == 20_000_000) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift index 33ecce970..047831fa9 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SwapInstructionBuilderUsdfToUsdcTests.swift @@ -159,7 +159,7 @@ struct SwapInstructionBuilderUsdfToUsdcTests { #expect(ix.accounts[6].publicKey == Self.feeDestination) } - @Test("Instruction 7 is CoinbaseStableSwapper Swap") + @Test("Instruction 7 is CoinbaseStableSwapper SwapV2") func instruction7_isCoinbaseSwap() { let ix = Self.makeInstructions()[7] #expect(ix.program == CoinbaseStableSwapperProgram.address) @@ -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 has 15 accounts and no whitelist; programs follow the user") + func swap_accountsHaveNoWhitelist() { let ix = Self.makeInstructions()[7] - let expected = CoinbaseStableSwapperProgram.deriveWhitelistAddress()!.publicKey - #expect(ix.accounts[12].publicKey == expected) + #expect(ix.accounts.count == 15) + #expect(ix.accounts[12].publicKey == TokenProgram.address) + #expect(ix.accounts[13].publicKey == AssociatedTokenProgram.address) + #expect(ix.accounts[14].publicKey == SystemProgram.address) } // MARK: - Discriminator + data layout (24 bytes) @@ -260,7 +262,7 @@ struct SwapInstructionBuilderUsdfToUsdcTests { @Test("Swap instruction data starts with the Anchor discriminator") func swap_discriminator() { let ix = Self.makeInstructions()[7] - let expected: [UInt8] = [248, 198, 158, 145, 225, 117, 135, 200] + let expected: [UInt8] = [43, 4, 237, 11, 26, 201, 30, 98] #expect(Array(ix.data.prefix(8)) == expected) }