mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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:
co-authored by
Claude Opus 5
parent
ebbfa9f124
commit
7c6d626e1c
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user