Files
pumpfun-bonkfun-bot_github/tests/regression/verify_pumpswap_account_layout.py
T
7c6d626e1c fix(cookbook): trade non-canonical PumpSwap pools (#218)
pumpswap_buy_token and pumpswap_sell_token failed on every pool that did not
graduate from a pump.fun bonding curve, with BuybackFeeRecipientNotAuthorized
(6053). The scripts always sent the pool-v2 account. That account belongs only
to a canonical pool; the buyback fee recipient and its quote ATA are always the
last two accounts and are read positionally, so on a non-canonical pool pool-v2
shifted them by one and the program read the pool-v2 PDA as the recipient.
Pool.coin_creator is the discriminator, set for canonical pools and left at
Pubkey::default() otherwise.

The IDL cannot answer this: it lists 23 accounts for buy against 25-26 on
chain, 21 for sell against 23-26, and it is byte-identical to upstream.
Upstream's BREAKING_FEE_RECIPIENT.md has the rule, in a qualifier that is easy
to read past: the pair goes after pool-v2 "for coins that graduate from bonding
curve", and is required either way.

Fixing that exposed a second bug the first had been hiding. Both scripts
hardcoded TOKEN_DECIMALS = 6, "standard for most pump.fun tokens" — true for
every graduated coin, and wrong for the non-canonical pools, which routinely
carry 9. Out by a factor of 1000, the quote and the slippage floor scale
together, so the sell reverted ExceededSlippage (6004) rather than merely
mispricing: Left: 2990, Right: 1500000. get_mint_info now returns the token
program and the decimals from the one account read that was already happening,
and raises instead of defaulting.

Verified on mainnet. Simulation across 10 pools: 5 non-canonical that all
failed 6053 before, 4 canonical that must not regress, and the pool from the
original report. Then real round trips on both kinds — a 9-decimal
non-canonical pool and a 6-decimal Token-2022 canonical one — buy and sell
landed in each, wallet returned to SOL.

verify_pumpswap_account_layout guards both bugs; checked against the pre-fix
code first, where it fails 4 of 4. Suite is 21/21.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 14:03:19 +02:00

238 lines
8.9 KiB
Python

"""Verify the PumpSwap trade scripts build the right accounts for either pool kind.
pump-amm's `buy`/`sell` take remaining accounts the vendored IDL does not list,
and the IDL is byte-identical to upstream, so the IDL cannot be the reference:
`buy` is 23 accounts there and 25 or 26 on chain, `sell` is 21 there and 23 to 26.
The extras are, in order, an optional `pool-v2` PDA, a buyback fee recipient, and
that recipient's quote-mint ATA.
The pair is read *positionally* from the end, and `pool-v2` only belongs to a
**canonical** pool — one that graduated from a pump.fun bonding curve.
`Pool.coin_creator` is the discriminator: set for canonical pools, left at
`Pubkey::default()` otherwise (upstream PUMP_SWAP_CREATOR_FEE_README.md). Sending
`pool-v2` on a non-canonical pool shifts the pair by one, so the program reads the
pool-v2 PDA as the buyback recipient and rejects it with
`BuybackFeeRecipientNotAuthorized` (6053). That was every non-canonical pool: 5 of
5 sampled failed before the fix, 10 of 10 simulate cleanly after it (2026-09-23).
Unblocking those pools then exposed a second bug they had been hiding. The scripts
hardcoded `TOKEN_DECIMALS = 6`, "standard for most pump.fun tokens" — true for
every coin that graduated from a bonding curve, and wrong for the non-canonical
pools, which routinely carry 9. Being out by a factor of 1000 scales the quote and
the slippage floor together, so a sell reverts `ExceededSlippage` (6004) rather
than merely mispricing: observed `Left: 2990`, `Right: 1500000`.
Offline machine checks, no network and no funds moved:
1. Both scripts gate the `pool-v2` account on `coin_creator`, and neither
appends it unconditionally.
2. The buyback recipient and its ATA are appended after that gate, so they stay
last whether or not `pool-v2` is present.
3. Neither script carries a hardcoded base-token decimals constant, and both
resolve decimals through `get_mint_info`.
4. `get_mint_info` returns the token program and the decimals from one account
read, and raises rather than defaulting when the mint cannot be read.
With `--live` it also re-reads pump-amm's `GlobalConfig` and checks the committed
recipient list still matches the chain, which is the part that silently rots.
Usage:
uv run tests/regression/verify_pumpswap_account_layout.py
uv run tests/regression/verify_pumpswap_account_layout.py --live
"""
import ast
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = (
PROJECT_ROOT / "cookbook" / "pumpswap" / "pumpswap_buy_token.py",
PROJECT_ROOT / "cookbook" / "pumpswap" / "pumpswap_sell_token.py",
)
# pump-amm's GlobalConfig, and where buyback_fee_recipients sits inside it:
# 8 discriminator + admin 32 + two u64 + disable_flags 1 + protocol[8*32]
# + coin_creator_fee_bps 8 + admin_set 32 + whitelist 32 + reserved_recipient 32
# + mayhem 1 + reserved[7*32] + cashback 1
GLOBAL_CONFIG = "ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw"
BUYBACK_OFFSET = 8 + 32 + 8 + 8 + 1 + 8 * 32 + 8 + 32 + 32 + 32 + 1 + 7 * 32 + 1
BUYBACK_COUNT = 8
def _source(path: Path) -> str:
return path.read_text()
def check_pool_v2_is_conditional() -> bool:
"""`pool-v2` must be gated on the pool having a coin creator."""
ok = True
for path in SCRIPTS:
src = _source(path)
if "find_pool_v2(base_mint)" not in src:
print(f" {path.name} no longer derives pool-v2 at all")
ok = False
continue
gated = re.search(
r"if coin_creator != DEFAULT_COIN_CREATOR:\s*\n\s*accounts\.append\(",
src,
)
if not gated:
print(f" {path.name} does not gate pool-v2 on coin_creator")
ok = False
return ok
def check_buyback_pair_stays_last() -> bool:
"""The recipient and its ATA are positional, so they must follow the gate."""
ok = True
for path in SCRIPTS:
src = _source(path)
gate = src.find("if coin_creator != DEFAULT_COIN_CREATOR:")
recipient = src.find("breaking_fee_recipient = random.choice(")
if gate < 0 or recipient < 0:
print(f" {path.name} is missing the gate or the recipient choice")
ok = False
continue
if recipient < gate:
print(f" {path.name} picks the buyback recipient before the gate")
ok = False
return ok
def check_no_hardcoded_base_decimals() -> bool:
"""A hardcoded 6 is right only for coins that graduated from a curve."""
ok = True
for path in SCRIPTS:
src = _source(path)
if re.search(r"^TOKEN_DECIMALS\s*=", src, re.M):
print(f" {path.name} still defines a TOKEN_DECIMALS constant")
ok = False
if "get_mint_info" not in src:
print(f" {path.name} does not resolve decimals via get_mint_info")
ok = False
if "base_decimals" not in src:
print(f" {path.name} never uses a resolved base_decimals")
ok = False
return ok
def check_get_mint_info_resolves_both_and_raises() -> bool:
"""One read, two fields, and no silent default."""
ok = True
for path in SCRIPTS:
tree = ast.parse(_source(path))
fn = next(
(
n
for n in ast.walk(tree)
if isinstance(n, ast.AsyncFunctionDef) and n.name == "get_mint_info"
),
None,
)
if fn is None:
print(f" {path.name} has no get_mint_info")
ok = False
continue
body = ast.unparse(fn)
if body.count("get_account_info") != 1:
print(f" {path.name}: get_mint_info should do exactly one account read")
ok = False
if "raise ValueError" not in body:
print(f" {path.name}: get_mint_info must raise, not default")
ok = False
returns = [n for n in ast.walk(fn) if isinstance(n, ast.Return) and n.value]
if not any(isinstance(r.value, ast.Tuple) for r in returns):
print(f" {path.name}: get_mint_info must return (program, decimals)")
ok = False
return ok
def check_recipient_list_matches_chain() -> bool:
"""--live: the committed buyback recipients must still be the authorized set."""
import asyncio
import base64
import os
import aiohttp
from dotenv import load_dotenv
from solders.pubkey import Pubkey
load_dotenv(PROJECT_ROOT / ".env")
endpoint = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
if not endpoint:
print(" SOLANA_NODE_RPC_ENDPOINT is unset; skipping")
return True
async def read() -> list[str]:
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "getAccountInfo",
"params": [
GLOBAL_CONFIG,
{"encoding": "base64", "commitment": "confirmed"},
],
}
async with (
aiohttp.ClientSession() as session,
session.post(endpoint, json=body) as response,
):
payload = await response.json()
raw = base64.b64decode(payload["result"]["value"]["data"][0])
return [
str(Pubkey(raw[BUYBACK_OFFSET + i * 32 : BUYBACK_OFFSET + (i + 1) * 32]))
for i in range(BUYBACK_COUNT)
]
on_chain = asyncio.run(read())
ok = True
for path in SCRIPTS:
block = _source(path).split("BREAKING_FEE_RECIPIENTS = [")[1].split("]")[0]
committed = re.findall(r'"([1-9A-HJ-NP-Za-km-z]{32,44})"', block)
if committed != on_chain:
print(f" {path.name} recipient list has drifted from GlobalConfig")
print(f" missing: {sorted(set(on_chain) - set(committed))}")
print(f" stale: {sorted(set(committed) - set(on_chain))}")
ok = False
return ok
def main() -> int:
live = "--live" in sys.argv
checks = [
("pool-v2 is gated on coin_creator", check_pool_v2_is_conditional),
("the buyback pair stays last", check_buyback_pair_stays_last),
("no hardcoded base-token decimals", check_no_hardcoded_base_decimals),
(
"get_mint_info reads once and raises",
check_get_mint_info_resolves_both_and_raises,
),
]
if live:
checks.append(
("committed recipients match the chain", check_recipient_list_matches_chain)
)
failed = 0
for label, check in checks:
try:
ok = check()
except Exception as error: # noqa: BLE001 - report and continue
print(f"FAIL {label}: {type(error).__name__}: {error}")
failed += 1
continue
print(f"{'PASS' if ok else 'FAIL'} {label}")
failed += 0 if ok else 1
if not live:
print("\n(skipping the network check; pass --live to read GlobalConfig)")
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())