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>
This commit is contained in:
Anton Sauchyk
2026-09-23 14:03:19 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent ebbfa9f124
commit 7c6d626e1c
4 changed files with 380 additions and 34 deletions
+21
View File
@@ -206,6 +206,7 @@ name individual scripts to run a subset.
| `verify_cookbook_arguments.py` | every cookbook script takes its input as a command-line argument |
| `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx |
| `verify_no_rpc_credentials_logged.py` | credentials are masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first |
| `verify_pumpswap_account_layout.py` | pump-amm's `pool-v2` account is gated on `coin_creator`, the buyback pair stays last, and base-token decimals are resolved rather than assumed; `--live` re-reads the authorized recipients from `GlobalConfig` |
Two mainnet simulations, also no funds moved:
@@ -468,6 +469,26 @@ The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-publi
work only while the high half is zero.
- pump-amm has **no** `buy_v2`/`sell_v2`. The AMM instruction names are
unchanged; only the pool layout and quoting moved.
- **`Pool.coin_creator` tells you whether a pool is canonical**, i.e. graduated
from a pump.fun bonding curve. It is `Pubkey::default()` on every other pool.
Two things follow, and they hid each other until 2026-09-23:
- `pool-v2` is sent **only for a canonical pool**. The buyback fee recipient
and its quote ATA are always the last two accounts and are read
positionally, so adding `pool-v2` on a non-canonical pool shifts them and
the program rejects the `pool-v2` PDA with `BuybackFeeRecipientNotAuthorized`
(6053). Upstream's BREAKING_FEE_RECIPIENT.md says the pair goes after
`pool-v2` "for coins that graduate from bonding curve", and separately that
the pair is needed either way — the qualifier is the whole rule. Account
counts: buy 26/27 canonical, 25/26 not; sell 24/26 canonical, 23/25 not.
- **Do not assume 6 base-token decimals.** Every graduated coin has 6, but
non-canonical pools routinely carry 9, and a factor of 1000 scales the quote
and the slippage floor together, so a sell reverts `ExceededSlippage` (6004)
rather than just mispricing. Resolve the mint's owner and its decimals from
the same `getAccountInfo`, as `get_mint_info` does.
- The vendored `pump_swap_idl.json` **under-reports these accounts** and is
byte-identical to upstream (checked 2026-09-23), so it cannot be the
reference: it lists 23 for `buy` against 25-26 on chain, and 21 for `sell`
against 23-26.
### Coin creation
+61 -18
View File
@@ -63,7 +63,6 @@ DEFAULT_SOL_AMOUNT = 0.001
DEFAULT_SLIPPAGE = 0.3 # 30% - maximum acceptable price movement during trade
# Token configuration
TOKEN_DECIMALS = 6 # Standard for most pump.fun tokens
# Program instruction discriminators (first 8 bytes identify the instruction)
BUY_DISCRIMINATOR = bytes.fromhex("66063d1201daebea")
@@ -305,6 +304,9 @@ def find_fee_config() -> Pubkey:
return derived_address
DEFAULT_COIN_CREATOR = Pubkey.default()
def find_pool_v2(base_mint: Pubkey) -> Pubkey:
"""Derive the PDA for the pool-v2 account (per-base-mint), required as the
last "pre-upgrade" account on every pump-swap buy/sell."""
@@ -454,24 +456,50 @@ async def calculate_token_pool_price(
# ============================================================================
async def get_token_program_id(client: AsyncClient, mint_address: Pubkey) -> Pubkey:
"""Determines if a mint uses TokenProgram or Token2022Program."""
# The `decimals` byte sits at this offset in both SPL Token and Token-2022 mints;
# Token-2022 appends its extensions after the base struct and never moves it.
MINT_DECIMALS_OFFSET = 44
async def get_mint_info(client: AsyncClient, mint_address: Pubkey) -> tuple[Pubkey, int]:
"""Read a mint's token program and decimals from one account fetch.
Both come off the same `getAccountInfo`, so resolving the decimals costs
nothing extra — and guessing them is not survivable. Every coin that
graduated from a pump.fun bonding curve has 6, which is why a hardcoded 6
held for so long, but a pool that was never a bonding-curve coin routinely
has 9. Getting it wrong by a factor of 1000 scales the quote and the
slippage floor together, so the sell reverts `ExceededSlippage` (6004)
instead of merely mispricing. Observed 2026-09-23: `Left: 2990`,
`Right: 1500000`.
Args:
client: Connected RPC client
mint_address: The mint to inspect
Returns:
(token program that owns the mint, the mint's decimals)
Raises:
ValueError: If the mint is missing or owned by an unknown program
"""
mint_info = await client.get_account_info(mint_address)
if not mint_info.value:
raise ValueError(f"Could not fetch mint info for {mint_address}")
owner = mint_info.value.owner
if owner == SYSTEM_TOKEN_PROGRAM:
return SYSTEM_TOKEN_PROGRAM
elif owner == TOKEN_2022_PROGRAM:
return TOKEN_2022_PROGRAM
else:
if owner not in (SYSTEM_TOKEN_PROGRAM, TOKEN_2022_PROGRAM):
raise ValueError(
f"Mint account {mint_address} is owned by an unknown program: {owner}"
)
data = mint_info.value.data
if len(data) <= MINT_DECIMALS_OFFSET:
raise ValueError(f"Mint account {mint_address} is too short to hold decimals")
return owner, data[MINT_DECIMALS_OFFSET]
async def buy_pump_swap(
client: AsyncClient,
@@ -484,6 +512,7 @@ async def buy_pump_swap(
pool_quote_token_account: Pubkey,
coin_creator_vault_authority: Pubkey,
coin_creator_vault_ata: Pubkey,
coin_creator: Pubkey,
sol_amount_to_spend: float,
slippage: float = 0.25,
) -> str | None:
@@ -516,7 +545,7 @@ async def buy_pump_swap(
Returns:
Transaction signature if successful, None otherwise
"""
token_program_id = await get_token_program_id(client, base_mint)
token_program_id, base_decimals = await get_mint_info(client, base_mint)
token_price_sol = await calculate_token_pool_price(
client,
pool_base_token_account,
@@ -526,10 +555,10 @@ async def buy_pump_swap(
print(f"Token price in SOL: {token_price_sol:.10f} SOL")
# Calculate expected token amount and maximum SOL we're willing to spend
base_amount_out = int((sol_amount_to_spend / token_price_sol) * 10**TOKEN_DECIMALS)
base_amount_out = int((sol_amount_to_spend / token_price_sol) * 10**base_decimals)
max_sol_input = int((sol_amount_to_spend * (1 + slippage)) * LAMPORTS_PER_SOL)
print(f"Buying {base_amount_out / (10**TOKEN_DECIMALS):.10f} tokens")
print(f"Buying {base_amount_out / (10**base_decimals):.10f} tokens")
print(f"Maximum SOL input: {max_sol_input / LAMPORTS_PER_SOL:.10f} SOL")
# Derive volume accumulator PDAs for incentive tracking
@@ -600,13 +629,26 @@ async def buy_pump_swap(
is_writable=True,
)
)
# pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account.
accounts.append(
AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False)
)
# pool-v2 belongs only to a *canonical* pool — one that graduated from a
# pump.fun bonding curve. `Pool.coin_creator` is the discriminator: it is set
# for canonical pools and left at `Pubkey::default()` for every other pool
# (upstream PUMP_SWAP_CREATOR_FEE_README.md). The two buyback accounts below
# are required either way, and they are read *positionally* from the end, so
# on a non-canonical pool sending pool-v2 shifts the pair by one and the
# program rejects the pool-v2 PDA with `BuybackFeeRecipientNotAuthorized`
# (6053). Confirmed 2026-09-23 against live trades on both kinds of pool, and
# against BREAKING_FEE_RECIPIENT.md, which qualifies the "after pool-v2"
# ordering with "for coins that graduate from bonding curve".
if coin_creator != DEFAULT_COIN_CREATOR:
accounts.append(
AccountMeta(
pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False
)
)
# 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER
# pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable).
# Buy counts: 26 non-cashback / 27 cashback.
# Buy counts on a canonical pool: 26 non-cashback / 27 cashback. One fewer
# on a non-canonical pool, which carries no pool-v2: 25 / 26.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS)
breaking_fee_quote_ata = get_associated_token_address(
@@ -744,7 +786,7 @@ async def buy(token_mint: Pubkey, sol_amount: float, slippage: float) -> None:
market_data = await get_market_data(client, market_address)
# Determine token program ID for the base mint
token_program_id = await get_token_program_id(client, token_mint)
token_program_id, _ = await get_mint_info(client, token_mint)
# Step 3: Derive PDAs needed for the transaction
coin_creator_vault_authority = find_coin_creator_vault(
@@ -766,6 +808,7 @@ async def buy(token_mint: Pubkey, sol_amount: float, slippage: float) -> None:
Pubkey.from_string(market_data["pool_quote_token_account"]),
coin_creator_vault_authority,
coin_creator_vault_ata,
Pubkey.from_string(market_data["coin_creator"]),
sol_amount,
slippage,
)
+61 -16
View File
@@ -53,7 +53,6 @@ PAYER = Keypair.from_bytes(PRIVATE_KEY)
DEFAULT_SLIPPAGE = 0.25 # 25% - maximum acceptable price movement during trade
# Token configuration
TOKEN_DECIMALS = 6 # Standard for most pump.fun tokens
# Program instruction discriminators (first 8 bytes identify the instruction)
SELL_DISCRIMINATOR = bytes.fromhex("33e685a4017f83ad")
@@ -256,6 +255,9 @@ def find_fee_config() -> Pubkey:
return derived_address
DEFAULT_COIN_CREATOR = Pubkey.default()
def find_pool_v2(base_mint: Pubkey) -> Pubkey:
"""Derive the PDA for the pool-v2 account (per-base-mint), required as the
last "pre-upgrade" account on every pump-swap buy/sell."""
@@ -416,24 +418,50 @@ async def calculate_token_pool_price(
# ============================================================================
async def get_token_program_id(client: AsyncClient, mint_address: Pubkey) -> Pubkey:
"""Determines if a mint uses TokenProgram or Token2022Program."""
# The `decimals` byte sits at this offset in both SPL Token and Token-2022 mints;
# Token-2022 appends its extensions after the base struct and never moves it.
MINT_DECIMALS_OFFSET = 44
async def get_mint_info(client: AsyncClient, mint_address: Pubkey) -> tuple[Pubkey, int]:
"""Read a mint's token program and decimals from one account fetch.
Both come off the same `getAccountInfo`, so resolving the decimals costs
nothing extra — and guessing them is not survivable. Every coin that
graduated from a pump.fun bonding curve has 6, which is why a hardcoded 6
held for so long, but a pool that was never a bonding-curve coin routinely
has 9. Getting it wrong by a factor of 1000 scales the quote and the
slippage floor together, so the sell reverts `ExceededSlippage` (6004)
instead of merely mispricing. Observed 2026-09-23: `Left: 2990`,
`Right: 1500000`.
Args:
client: Connected RPC client
mint_address: The mint to inspect
Returns:
(token program that owns the mint, the mint's decimals)
Raises:
ValueError: If the mint is missing or owned by an unknown program
"""
mint_info = await client.get_account_info(mint_address)
if not mint_info.value:
raise ValueError(f"Could not fetch mint info for {mint_address}")
owner = mint_info.value.owner
if owner == SYSTEM_TOKEN_PROGRAM:
return SYSTEM_TOKEN_PROGRAM
elif owner == TOKEN_2022_PROGRAM:
return TOKEN_2022_PROGRAM
else:
if owner not in (SYSTEM_TOKEN_PROGRAM, TOKEN_2022_PROGRAM):
raise ValueError(
f"Mint account {mint_address} is owned by an unknown program: {owner}"
)
data = mint_info.value.data
if len(data) <= MINT_DECIMALS_OFFSET:
raise ValueError(f"Mint account {mint_address} is too short to hold decimals")
return owner, data[MINT_DECIMALS_OFFSET]
# ============================================================================
# Associated Token Account (ATA) Creation
@@ -486,6 +514,8 @@ async def sell_pump_swap(
pool_quote_token_account: Pubkey,
coin_creator_vault_authority: Pubkey,
coin_creator_vault_ata: Pubkey,
coin_creator: Pubkey,
base_decimals: int,
slippage: float = 0.25,
) -> str | None:
"""Execute a token sell on the PUMP AMM with slippage protection.
@@ -522,7 +552,7 @@ async def sell_pump_swap(
)
).value.amount
)
token_balance_decimal = token_balance / 10**TOKEN_DECIMALS
token_balance_decimal = token_balance / 10**base_decimals
print(f"Token balance: {token_balance_decimal}")
@@ -611,13 +641,26 @@ async def sell_pump_swap(
pubkey=user_volume_accumulator, is_signer=False, is_writable=True
),
])
# pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account.
accounts.append(
AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False)
)
# pool-v2 belongs only to a *canonical* pool — one that graduated from a
# pump.fun bonding curve. `Pool.coin_creator` is the discriminator: it is set
# for canonical pools and left at `Pubkey::default()` for every other pool
# (upstream PUMP_SWAP_CREATOR_FEE_README.md). The two buyback accounts below
# are required either way, and they are read *positionally* from the end, so
# on a non-canonical pool sending pool-v2 shifts the pair by one and the
# program rejects the pool-v2 PDA with `BuybackFeeRecipientNotAuthorized`
# (6053). Confirmed 2026-09-23 against live trades on both kinds of pool, and
# against BREAKING_FEE_RECIPIENT.md, which qualifies the "after pool-v2"
# ordering with "for coins that graduate from bonding curve".
if coin_creator != DEFAULT_COIN_CREATOR:
accounts.append(
AccountMeta(
pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False
)
)
# 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER
# pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable).
# Sell counts: 24 non-cashback / 26 cashback.
# Sell counts on a canonical pool: 24 non-cashback / 26 cashback. One fewer
# on a non-canonical pool, which carries no pool-v2: 23 / 25.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS)
breaking_fee_quote_ata = get_associated_token_address(
@@ -693,7 +736,7 @@ async def sell(token_mint: Pubkey, slippage: float) -> None:
market_data = await get_market_data(client, market_address)
# Determine token program ID for the base mint
token_program_id = await get_token_program_id(client, token_mint)
token_program_id, base_decimals = await get_mint_info(client, token_mint)
# Step 3: Derive PDAs needed for the transaction
coin_creator_vault_authority = find_coin_creator_vault(
@@ -716,6 +759,8 @@ async def sell(token_mint: Pubkey, slippage: float) -> None:
Pubkey.from_string(market_data["pool_quote_token_account"]),
coin_creator_vault_authority,
coin_creator_vault_ata,
Pubkey.from_string(market_data["coin_creator"]),
base_decimals,
slippage,
)
@@ -0,0 +1,237 @@
"""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())