Skip to content

Commit c7b18c4

Browse files
authored
fix(solana): read pool fee recipient at the deployed offset (#761)
1 parent 07c8b95 commit c7b18c4

4 files changed

Lines changed: 164 additions & 17 deletions

File tree

‎FlipcashCore/Sources/FlipcashCore/Solana/Programs/CoinbaseStableSwapperProgram.PoolAccount.swift‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,39 @@ extension CoinbaseStableSwapperProgram {
99

1010
/// The on-chain liquidity pool account.
1111
///
12-
/// Layout: `[8 discriminator][32 operations_authority][32 pause_authority][32 fee_recipient]...`
12+
/// Layout, decoded from the live pool account `CrDL9SoCyW1tBgn8k7rgGSpWhnszneWDbvKvqPAU4PL9`
13+
/// (owner `pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F`):
14+
/// ```
15+
/// 0 [8] discriminator sha256("account:LiquidityPool")[0..8]
16+
/// 8 [32] operations_authority
17+
/// 40 [32] pause_authority
18+
/// 72 [32] unnamed pubkey <- not described by the published IDL
19+
/// 104 [32] unnamed pubkey <- not described by the published IDL
20+
/// 136 [32] fee_recipient
21+
/// 168 vec<pubkey> (length 2 on the live account), then supported_tokens,
22+
/// fee_rate, swaps_paused, liquidity_paused, bump — not parsed by this type
23+
/// ```
24+
/// The program's published Anchor IDL does not describe this struct. It lists three
25+
/// pubkeys, then `supported_tokens`, which puts `fee_recipient` at offset 72; mainnet has
26+
/// it at 136. Regenerating this layout from that IDL reintroduces the bug this type exists
27+
/// to fix, and neither of the two pubkeys the IDL omits has a published name to give it.
1328
public struct PoolAccount: Equatable, Sendable {
1429

15-
public let feeRecipient: PublicKey
30+
/// `sha256("account:LiquidityPool")[0..8]`, Anchor's account discriminator for this type.
31+
private static let discriminator: [UInt8] = [66, 38, 17, 64, 188, 80, 68, 129]
32+
33+
private static let feeRecipientOffset = 8 + 32 + 32 + 32 + 32
1634

17-
private static let feeRecipientOffset = 8 + 32 + 32
35+
public let feeRecipient: PublicKey
1836

19-
/// Parses the raw pool account data, returning `nil` when the data is
20-
/// too short or the fee recipient bytes are not a valid public key.
37+
/// Parses the raw pool account data, returning `nil` when the data is too short,
38+
/// the leading discriminator doesn't match `LiquidityPool`, or the fee recipient
39+
/// bytes are not a valid public key.
2140
public init?(accountData: Data) {
41+
guard accountData.prefix(Self.discriminator.count).elementsEqual(Self.discriminator) else {
42+
return nil
43+
}
44+
2245
var payload = accountData.tail(from: Self.feeRecipientOffset)
2346
guard let feeRecipient = try? PublicKey(payload.consume(PublicKey.length)) else {
2447
return nil
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// CoinbaseStableSwapperDepositTransactionTests.swift
3+
// FlipcashCoreTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import FlipcashCore
9+
10+
/// Pins the bytes an external-wallet USDC deposit puts on the wire, starting from the real
11+
/// pool account rather than a hand-written fee recipient.
12+
///
13+
/// The chain under test is the one the deep-link deposit runs: parse the pool account, take
14+
/// `fee_recipient` out of it, build the USDC→USDF swap against the VM deposit, encode. A wrong
15+
/// offset in `PoolAccount` yields a valid-looking `PublicKey` and a transaction that encodes
16+
/// fine, so only the end-to-end bytes catch it.
17+
///
18+
/// The fixture below was accepted by `simulateTransaction` on mainnet (no error, 68922 compute
19+
/// units, `Swapped 5000000 tokens`). Reading `fee_recipient` at the offset the published IDL
20+
/// implies instead fails the same simulation with `ConstraintAddress` (2012).
21+
@Suite("CoinbaseStableSwapper deposit transaction bytes")
22+
struct CoinbaseStableSwapperDepositTransactionTests {
23+
24+
// MARK: - Fixtures
25+
26+
/// Base64 of the first 168 bytes (discriminator through `fee_recipient`) of the real pool
27+
/// account `CrDL9SoCyW1tBgn8k7rgGSpWhnszneWDbvKvqPAU4PL9`, captured from mainnet.
28+
private static let poolAccountBase64 = """
29+
QiYRQLxQRIEFHqE9vluQFKO1wbEwnd22aRe9qGrV03SIsz1AV7GqT/yEzcR/f+ALaKG4KMxbBfZ5dTNFPqxxZHMfbTqNfj6St0p++yObz2IILMcKsoko07O+oRSDek7YwzrH9TroL1+QbHdT/t9qpcq4Kyx8OPWZm79AIUM9UlN+X6ujF0hPgzT4z8SXtrVTfBhZj7LzSPTgCpHoi6cjfPqXvflOJvRB
30+
"""
31+
32+
/// A mainnet wallet with a USDC balance, so these bytes can be replayed through
33+
/// `simulateTransaction` unchanged when the pool or the program moves.
34+
private static let sender = try! PublicKey(base58: "13V7ou4zHHwDVaAGWxqHSwU2sVzRR4m62XWqCFxhA5fD")
35+
private static let owner = try! PublicKey([UInt8](repeating: 7, count: 32))
36+
private static let swapId = try! PublicKey([UInt8](repeating: 9, count: 32))
37+
private static let blockhash = try! Hash([UInt8](repeating: 99, count: 32))
38+
private static let amount: UInt64 = 5_000_000
39+
40+
/// Any change here is a wire-format change on the deposit path. Re-simulate before updating.
41+
private static let expectedBase64 = """
42+
AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA8WAKL0PLpNizvAoz6QRhxwuE+0xI1gTXBS8xC2iFAT9YAIIEbBeYxANDSqQKEPw/bim+87xyfndOcR1Z8MXipwRQhN3zo5EjvB3buXfyApNPeiOya5xLRmU4amnmybe8G0ZvC2QXkvoK+rzDvts+c808FFOCj9M+Gw1VaTwlFFQuCIGuzkYUNbUsQHluSoCkWyRnCKUYxj6p9wIBIqXQISoIsQLEV0nZ6/A9+EnNdx0SgwdXMN9srCBKAsQrgkmUgy/UHEeoQ9iL75ZI2OJCdbgb8cuXN12zjqg9u576KCFfMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABUpTUPhdyILWFKVWcniKKW3fHqur0KYGeIhJMvTu9qAGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDEFZLOrgP4XlL2Cp/OoiptfGnqhwN8d2wUpZw0Pc/EQPv+3THgl3KU5TYxzuZoU4rxDFSBlWplQ+ubexfVTLGBesefflfZmEQHRAN54c7gRVD3jN0yyu9XLWr5cM75ecK4oRckQvAxiEdzAHgKozCP7MPozCSuUSsn/Qt7oVRio0+M/El7a1U3wYWY+y80j04AqR6IunI3z6l735Tib0QT3TxICKPU+zn67Jy6SbWmNKUu3hFUj1JKog8s1G74BxjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FmwC/Uf+cl7MpDUyy9l2C2TYANmRyEZdjSa69H06vFFtr1GKwT/p1DYV1ksSW6M2cODznxaflH16bTnFDZNvRkvxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWFjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjYwgIAAUCQA0DAAgACQPoAwAAAAAAABIHAAQAEQcLCgEBEgcABRQRBwsKAQESBwADABUHCwoBAQkAK2NHZkhpQzZLZ2czRnBGWnZnd0djc3dzQ1J0cDRhQlAyZnp1WFJRUGl6dU4MEBMODwECAwQGEBURAA0LEgcY+MaekeF1h8hAS0wAAAAAAEBLTAAAAAAACwMEBQAJA0BLTAAAAAAA
43+
"""
44+
45+
// MARK: - Tests
46+
47+
@Test("The real pool account yields the fee recipient the program enforces")
48+
func poolAccount_parsesDeployedFeeRecipient() throws {
49+
let data = try #require(Data(base64Encoded: Self.poolAccountBase64))
50+
let pool = try #require(CoinbaseStableSwapperProgram.PoolAccount(accountData: data))
51+
#expect(pool.feeRecipient == (try PublicKey(base58: "4ZnFXk7KyB5khDqjWSHqHBQH1nQCnmvkr1pRFivWcP7e")))
52+
}
53+
54+
@Test("Pool bytes through to encoded transaction produce the simulated byte sequence")
55+
func depositTransaction_encodesToFixture() throws {
56+
let data = try #require(Data(base64Encoded: Self.poolAccountBase64))
57+
let pool = try #require(CoinbaseStableSwapperProgram.PoolAccount(accountData: data))
58+
59+
let instructions = SwapInstructionBuilder.buildUsdcToUsdfSwapInstructions(
60+
sender: Self.sender,
61+
owner: Self.owner,
62+
amount: Self.amount,
63+
pool: .coinbaseStableSwapper(feeRecipient: pool.feeRecipient),
64+
swapId: Self.swapId,
65+
destination: .vmDeposit
66+
)
67+
68+
let transaction = SolanaTransaction(
69+
payer: Self.sender,
70+
recentBlockhash: Self.blockhash,
71+
instructions: instructions
72+
)
73+
74+
#expect(instructions.count == 8)
75+
#expect(transaction.encode() == (try #require(Data(base64Encoded: Self.expectedBase64))))
76+
}
77+
78+
@Test("The encoded transaction fits in a single packet")
79+
func depositTransaction_fitsPacketLimit() throws {
80+
let data = try #require(Data(base64Encoded: Self.expectedBase64))
81+
#expect(data.count <= 1232)
82+
}
83+
}

‎FlipcashCore/Tests/FlipcashCoreTests/CoinbaseStableSwapperPoolAccountTests.swift‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,36 @@ import Testing
55
@Suite("CoinbaseStableSwapperProgram.PoolAccount")
66
struct CoinbaseStableSwapperPoolAccountTests {
77

8-
/// Minimum account length: 8 discriminator + 32 ops authority
9-
/// + 32 pause authority + 32 fee recipient.
10-
private static let minimumLength = 8 + 32 + 32 + 32
11-
12-
private static func accountData(feeRecipient: [UInt8], trailing: Int = 0) -> Data {
13-
var data = Data(repeating: 0xAA, count: 8) // discriminator
14-
data.append(Data(repeating: 0xBB, count: 32)) // operations authority
15-
data.append(Data(repeating: 0xCC, count: 32)) // pause authority
8+
/// `sha256("account:LiquidityPool")[0..8]` — the real Anchor discriminator for this account.
9+
private static let discriminator: [UInt8] = [66, 38, 17, 64, 188, 80, 68, 129]
10+
11+
/// Minimum account length: 8 discriminator + 32 operations authority + 32 pause authority
12+
/// + 32 unnamed pubkey + 32 unnamed pubkey + 32 fee recipient.
13+
private static let minimumLength = 8 + 32 + 32 + 32 + 32 + 32
14+
15+
/// Base64 of the first 168 bytes (discriminator through `fee_recipient`) of the real
16+
/// pool account `CrDL9SoCyW1tBgn8k7rgGSpWhnszneWDbvKvqPAU4PL9`, captured from mainnet.
17+
private static let realCapturedAccountDataBase64 = """
18+
QiYRQLxQRIEFHqE9vluQFKO1wbEwnd22aRe9qGrV03SIsz1AV7GqT/yEzcR/f+ALaKG4KMxbBfZ5dTNFPqxxZHMfbTqNfj6St0p++yObz2IILMcKsoko07O+oRSDek7YwzrH9TroL1+QbHdT/t9qpcq4Kyx8OPWZm79AIUM9UlN+X6ujF0hPgzT4z8SXtrVTfBhZj7LzSPTgCpHoi6cjfPqXvflOJvRB
19+
"""
20+
21+
private static func accountData(
22+
feeRecipient: [UInt8],
23+
discriminator: [UInt8] = Self.discriminator,
24+
trailing: Int = 0
25+
) -> Data {
26+
var data = Data(discriminator)
27+
data.append(Data(repeating: 0xBB, count: 32)) // operations_authority
28+
data.append(Data(repeating: 0xCC, count: 32)) // pause_authority
29+
data.append(Data(repeating: 0xEE, count: 32)) // unnamed pubkey (offset 72)
30+
data.append(Data(repeating: 0xFA, count: 32)) // unnamed pubkey (offset 104)
1631
data.append(Data(feeRecipient))
1732
data.append(Data(repeating: 0xDD, count: trailing))
1833
return data
1934
}
2035

2136
@Test(
22-
"Parses the fee recipient at offset 72, with or without trailing fields",
37+
"Parses the fee recipient at offset 136, with or without trailing fields",
2338
arguments: [0, 128]
2439
)
2540
func initAccountData_validLayout_parsesFeeRecipient(trailing: Int) throws {
@@ -32,10 +47,28 @@ struct CoinbaseStableSwapperPoolAccountTests {
3247
#expect(account.feeRecipient == (try PublicKey(feeRecipientBytes)))
3348
}
3449

50+
@Test("Decodes the real pool account and recovers the correct fee recipient")
51+
func initAccountData_realCapturedAccount_parsesFeeRecipient() throws {
52+
let data = try #require(Data(base64Encoded: Self.realCapturedAccountDataBase64))
53+
let account = try #require(CoinbaseStableSwapperProgram.PoolAccount(accountData: data))
54+
#expect(account.feeRecipient == (try PublicKey(base58: "4ZnFXk7KyB5khDqjWSHqHBQH1nQCnmvkr1pRFivWcP7e")))
55+
}
56+
3557
@Test("Rejects account data shorter than the fee recipient bounds")
3658
func initAccountData_shortData_returnsNil() {
37-
let short = Data(repeating: 0xAA, count: Self.minimumLength - 1)
38-
#expect(CoinbaseStableSwapperProgram.PoolAccount(accountData: short) == nil)
59+
let short = Self.accountData(feeRecipient: [UInt8](repeating: 7, count: 32))
60+
.prefix(Self.minimumLength - 1)
61+
#expect(CoinbaseStableSwapperProgram.PoolAccount(accountData: Data(short)) == nil)
62+
}
63+
64+
@Test("Rejects a discriminator that doesn't match LiquidityPool")
65+
func initAccountData_mismatchedDiscriminator_returnsNil() {
66+
let wrongDiscriminator = [UInt8](repeating: 0, count: 8)
67+
let data = Self.accountData(
68+
feeRecipient: [UInt8](repeating: 7, count: 32),
69+
discriminator: wrongDiscriminator
70+
)
71+
#expect(CoinbaseStableSwapperProgram.PoolAccount(accountData: data) == nil)
3972
}
4073

4174
@Test("Ignores slice offsets — parses relative to the data's start")

‎FlipcashTests/WalletConnectionPoolResolutionTests.swift‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@ import FlipcashCore
1212
@Suite("WalletConnection.resolveFundSwapPool")
1313
struct WalletConnectionPoolResolutionTests {
1414

15+
/// `sha256("account:LiquidityPool")[0..8]` — the real Anchor discriminator for this
16+
/// account. `PoolAccount` rejects data that doesn't start with these bytes.
17+
private nonisolated static let poolAccountDiscriminator: [UInt8] = [66, 38, 17, 64, 188, 80, 68, 129]
18+
19+
/// Fee recipient sits at offset 136: discriminator (8) + operations_authority (32)
20+
/// + pause_authority (32) + two unnamed pubkeys (32 each) that the published IDL
21+
/// does not describe.
1522
private nonisolated static func poolAccountData(feeRecipient: [UInt8]) -> Data {
16-
var data = Data(repeating: 0, count: 8 + 32 + 32)
23+
var data = Data(Self.poolAccountDiscriminator)
24+
data.append(Data(repeating: 0, count: 32 + 32 + 32 + 32))
1725
data.append(Data(feeRecipient))
1826
return data
1927
}

0 commit comments

Comments
 (0)