feat(pumpfun): migrate to buy_v2/sell_v2 and support non-SOL quote assets (#176)

Refresh the vendored IDLs from pump-fun/pump-public-docs @ 9c82f61 and move all
pump.fun trading onto the v2 instruction interface. This is required, not
optional: legacy buy/sell cannot trade coins paired against a quote asset other
than SOL, and USDC is already whitelisted in the on-chain Global account.

Protocol changes absorbed:

- buy_v2 (27 accounts) / sell_v2 (26 accounts) replace the legacy instructions.
  Every account is mandatory and the order is identical for all coins, so the
  conditional cashback/mayhem account lists are gone. Legacy remains available
  via PumpFunInstructionBuilder(use_legacy_instructions=True).
- BondingCurve is 151 bytes: virtual_sol_reserves -> virtual_quote_reserves,
  real_sol_reserves -> real_quote_reserves, plus quote_mint at offset 83. Old
  field names are kept as aliases so existing callers keep working.
- v2 instruction data drops the track_volume OptionBool; amounts are in the
  quote mint's raw units rather than always lamports.
- create_v2 carries a non-SOL quote mint as optional remaining accounts 17-19,
  and CreateEvent gained quote_mint, so extreme_fast_mode can resolve the quote
  asset without an extra fetch.

USDC support: new trade.quote_amounts and filters.allowed_quote_mints config,
accepting "sol"/"usdc" aliases or raw mints. Amounts are per-quote-mint because
1 USDC and 1 SOL are not interchangeable. A coin whose quote mint has no
configured amount is skipped rather than traded at the wrong size, so SOL-only
configs are unaffected.

Bug fixes found while verifying:

- The logs and blocks listeners set no websocket max_size, so any frame over
  1 MiB closed the connection with 1009 and the token in it was lost. Raised
  to 32 MiB.
- PumpSwap priced against the raw quote vault balance, ignoring the new
  Pool.virtual_quote_reserves (i128 at offset 245; live pools are 301 bytes).
  Upstream's note that this field is 0 everywhere is out of date: a live pool
  carries 17.58 SOL against a 148 SOL vault, a 10.15% price error.
- The seller read curve state once at confirmed commitment and silently fell
  back to create-time values, risking a stale creator_vault and ConstraintSeeds.
  It now retries at processed, matching the buyer.
- Account cleanup would burn wrapped SOL when force_burn was set, destroying
  value that closing the account returns. WSOL is now closed without burning.
- The mint scripts treated a landed transaction as a successful one, so a
  reverted buy printed as success. They now assert the on-chain result.

Compute unit limits retuned from mainnet measurements: buy 100k -> 180k,
sell 60k -> 120k. Mint-and-buy is no longer atomic, because create_v2 plus
buy_v2 exceeds the 1232-byte transaction limit; both mint scripts send two
transactions.

Adds learning-examples/pump_v2.py as one shared, standalone v2 toolkit for the
example scripts, and three verification scripts: an offline layout check
against the IDL, a no-funds mainnet simulation, and a live listener matrix that
buys, sells and closes the ATA per listener.

Verified on mainnet: all four listeners (geyser, logs, blocks, pumpportal) and
all eight example scripts completed a real buy, sell and ATA close, each
confirmed by reading the transaction result back rather than trusting
confirmation alone. The USDC path is verified structurally only; no USDC-paired
coin could be found on-chain to exercise it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-28 17:58:33 +02:00
committed by GitHub
parent 3b88a06d9d
commit 02343b775b
34 changed files with 10949 additions and 3987 deletions
+409
View File
@@ -0,0 +1,409 @@
"""Verify every listener with a real buy_v2 + sell_v2 + ATA cleanup on mainnet.
WARNING: this submits real transactions and spends real funds.
For each listener (geyser, logs, blocks, pumpportal) it detects a live token,
buys it, sells it, then closes the base-token ATA so the ~0.002 SOL of rent is
reclaimed rather than stranded. Tests run sequentially so the same funds are
recycled across listeners.
Each listener exercises a different event-parsing path into the same v2 trade
code, which is the point: pumpportal in particular carries no mayhem/cashback/
quote_mint flags, so it relies entirely on the on-chain curve refresh.
Usage:
uv run learning-examples/live_listener_matrix.py --yes
uv run learning-examples/live_listener_matrix.py --yes --listeners geyser,logs
uv run learning-examples/live_listener_matrix.py --cleanup-only <MINT> [<MINT>...]
"""
import asyncio
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from cleanup.manager import AccountCleanupManager # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.pubkeys import LAMPORTS_PER_SOL, SystemAddresses # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
HOLD_SECONDS = 5
PRIORITY_FEE = 1_000_000
# This harness verifies plumbing, not profitability. Brand-new coins routinely
# lose 90% of their curve SOL within seconds of creation, and a realistic
# sell_slippage of 0.3 then makes the program reject the sell with
# TooLittleSolReceived (6003) — a correct guard, but it stops us proving the
# 26-account sell_v2 actually lands. Accept almost any payout instead; the
# position is worth well under a lamport-thousandth of a SOL either way.
SELL_SLIPPAGE = 0.95
BUY_SLIPPAGE = 0.3
DETECT_TIMEOUT_SECONDS = 150.0
# Time to let a close/sell finalize before reading balances or account state.
SETTLE_SECONDS = 20
ALL_LISTENERS = ("geyser", "logs", "blocks", "pumpportal")
@dataclass
class LegResult:
"""Outcome of one listener's buy/sell/cleanup cycle."""
listener: str
detected: str | None = None
symbol: str | None = None
quote_mint: str | None = None
buy_ok: bool = False
buy_sig: str | None = None
sell_ok: bool = False
sell_sig: str | None = None
cleanup_ok: bool = False
start_sol: float = 0.0
end_sol: float = 0.0
notes: list[str] = field(default_factory=list)
@property
def passed(self) -> bool:
"""Whether the whole cycle succeeded."""
return self.buy_ok and self.sell_ok and self.cleanup_ok
def make_listener(listener_type: str):
"""Build a listener of the given type wired to this project's endpoints.
Args:
listener_type: One of geyser, logs, blocks, pumpportal
Returns:
Configured listener instance
"""
return ListenerFactory.create_listener(
listener_type=listener_type,
wss_endpoint=os.environ.get("SOLANA_NODE_WSS_ENDPOINT"),
geyser_endpoint=os.environ.get("GEYSER_ENDPOINT"),
geyser_api_token=os.environ.get("GEYSER_API_TOKEN"),
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
async def detect(listener_type: str) -> TokenInfo | None:
"""Wait for one new pump.fun token from the given listener.
Args:
listener_type: Listener to use
Returns:
TokenInfo, or None on timeout
"""
listener = make_listener(listener_type)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(DETECT_TIMEOUT_SECONDS / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
await asyncio.sleep(0)
return seen[0] if seen else None
async def sol_balance(client: SolanaClient, wallet: Wallet) -> float:
"""Read the wallet's SOL balance at finalized commitment.
Reads immediately after a transaction confirms can still serve a slot that
predates it, which silently produces "net change: 0" style nonsense. Ask for
finalized explicitly so the reported numbers mean something.
Args:
client: RPC client
wallet: Wallet to query
Returns:
Balance in SOL
"""
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "getBalance",
"params": [str(wallet.pubkey), {"commitment": "finalized"}],
}
)
lamports = (response or {}).get("result", {}).get("value")
if lamports is None:
raise RuntimeError(f"getBalance failed for {wallet.pubkey}: {response}")
return lamports / LAMPORTS_PER_SOL
async def ata_is_closed(client: SolanaClient, ata: Pubkey) -> bool:
"""Check whether an ATA is closed, at finalized commitment.
Args:
client: RPC client
ata: Associated token account address
Returns:
True if the account no longer exists
"""
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "getAccountInfo",
"params": [str(ata), {"encoding": "base64", "commitment": "finalized"}],
}
)
return (response or {}).get("result", {}).get("value") is None
async def run_leg(
listener_type: str,
client: SolanaClient,
wallet: Wallet,
priority_fee_manager: PriorityFeeManager,
) -> LegResult:
"""Detect, buy, sell and clean up for one listener.
Args:
listener_type: Listener under test
client: RPC client
wallet: Trading wallet
priority_fee_manager: Priority fee strategy
Returns:
LegResult describing the cycle
"""
result = LegResult(listener=listener_type)
result.start_sol = await sol_balance(client, wallet)
print(
f"\n{'=' * 62}\n{listener_type.upper()} (start {result.start_sol:.9f} SOL)\n{'=' * 62}"
)
print(
f"[{listener_type}] waiting for a token (up to {DETECT_TIMEOUT_SECONDS:.0f}s)..."
)
token_info = await detect(listener_type)
if token_info is None:
result.notes.append("no token detected before timeout")
print(f"[{listener_type}] no token detected")
return result
result.detected = str(token_info.mint)
result.symbol = token_info.symbol
result.quote_mint = str(token_info.quote_mint)
print(f"[{listener_type}] detected {token_info.symbol} ({token_info.mint})")
print(f"[{listener_type}] quote_mint from listener: {token_info.quote_mint}")
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=BUY_SLIPPAGE,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=True,
)
seller = PlatformAwareSeller(
client, wallet, priority_fee_manager, slippage=SELL_SLIPPAGE, max_retries=1
)
buy = await buyer.execute(token_info)
result.buy_ok = buy.success
result.buy_sig = str(buy.tx_signature) if buy.tx_signature else None
print(f"[{listener_type}] BUY ok={buy.success} tx={buy.tx_signature}")
if not buy.success:
result.notes.append(f"buy failed: {buy.error_message}")
else:
await asyncio.sleep(HOLD_SECONDS)
sell = await seller.execute(token_info, buy.amount, buy.price)
result.sell_ok = sell.success
result.sell_sig = str(sell.tx_signature) if sell.tx_signature else None
print(f"[{listener_type}] SELL ok={sell.success} tx={sell.tx_signature}")
if not sell.success:
result.notes.append(f"sell failed: {sell.error_message}")
# Always attempt cleanup, even after a failed buy: a partially-created ATA
# still holds rent.
token_program = token_info.token_program_id or SystemAddresses.TOKEN_2022_PROGRAM
manager = AccountCleanupManager(
client, wallet, priority_fee_manager, use_priority_fee=False, force_burn=True
)
print(f"[{listener_type}] cleaning up ATA for {token_info.mint}...")
await manager.cleanup_ata(token_info.mint, token_program)
# Let the close finalize before judging it; otherwise the check races the
# very transaction it is verifying.
await asyncio.sleep(SETTLE_SECONDS)
ata = wallet.get_associated_token_address(token_info.mint, token_program)
result.cleanup_ok = await ata_is_closed(client, ata)
if not result.cleanup_ok:
result.notes.append(f"ATA {ata} still open after cleanup")
result.end_sol = await sol_balance(client, wallet)
print(
f"[{listener_type}] cleanup ok={result.cleanup_ok} "
f"end {result.end_sol:.9f} SOL "
f"(net {result.end_sol - result.start_sol:+.9f})"
)
return result
async def cleanup_only(mints: list[str]) -> int:
"""Close ATAs for the given mints and exit.
Args:
mints: Base mint addresses whose ATAs should be closed
Returns:
Process exit code
"""
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
try:
before = await sol_balance(client, wallet)
print(f"balance before cleanup: {before:.9f} SOL")
manager = AccountCleanupManager(
client,
wallet,
priority_fee_manager,
use_priority_fee=False,
force_burn=True,
)
for mint_str in mints:
mint = Pubkey.from_string(mint_str)
for program in (
SystemAddresses.TOKEN_2022_PROGRAM,
SystemAddresses.TOKEN_PROGRAM,
):
ata = wallet.get_associated_token_address(mint, program)
if await ata_is_closed(client, ata):
continue
print(f"closing {ata} (mint {mint_str}, program {program})")
await manager.cleanup_ata(mint, program)
await asyncio.sleep(SETTLE_SECONDS)
print(f" closed: {await ata_is_closed(client, ata)}")
after = await sol_balance(client, wallet)
print(f"balance after cleanup: {after:.9f} SOL (net {after - before:+.9f})")
return 0
finally:
await client.close()
async def main() -> int:
"""Run the listener matrix.
Returns:
Process exit code (0 if every requested listener passed)
"""
if "--cleanup-only" in sys.argv:
index = sys.argv.index("--cleanup-only")
mints = sys.argv[index + 1 :]
if not mints:
print("--cleanup-only needs at least one mint")
return 2
return await cleanup_only(mints)
if "--yes" not in sys.argv:
print("This spends real funds. Re-run with --yes to proceed.")
return 2
listeners = list(ALL_LISTENERS)
if "--listeners" in sys.argv:
listeners = sys.argv[sys.argv.index("--listeners") + 1].split(",")
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
results: list[LegResult] = []
try:
opening = await sol_balance(client, wallet)
print(f"wallet: {wallet.pubkey}\nopening balance: {opening:.9f} SOL")
print(f"listeners under test: {', '.join(listeners)}")
for listener_type in listeners:
try:
results.append(
await run_leg(listener_type, client, wallet, priority_fee_manager)
)
except Exception as error: # noqa: BLE001
failed = LegResult(listener=listener_type)
failed.notes.append(f"crashed: {error}")
results.append(failed)
print(f"[{listener_type}] CRASHED: {error}")
closing = await sol_balance(client, wallet)
finally:
await client.close()
print(f"\n{'=' * 62}\nSUMMARY\n{'=' * 62}")
header = (
f"{'listener':<12} {'detect':<7} {'buy':<5} {'sell':<5} {'cleanup':<8} symbol"
)
print(header)
for item in results:
print(
f"{item.listener:<12} "
f"{'yes' if item.detected else 'no':<7} "
f"{'ok' if item.buy_ok else '-':<5} "
f"{'ok' if item.sell_ok else '-':<5} "
f"{'ok' if item.cleanup_ok else '-':<8} "
f"{item.symbol or ''}"
)
for note in item.notes:
print(f"{'':<12} note: {note}")
print(f"\nopening {opening:.9f} SOL -> closing {closing:.9f} SOL")
print(f"total net change: {closing - opening:+.9f} SOL")
for item in results:
if item.buy_sig:
print(f" {item.listener} buy: {item.buy_sig}")
if item.sell_sig:
print(f" {item.listener} sell: {item.sell_sig}")
passed = [item for item in results if item.passed]
print(f"\n{len(passed)}/{len(results)} listeners passed the full cycle")
return 0 if len(passed) == len(results) else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+160
View File
@@ -0,0 +1,160 @@
"""Execute one real buy_v2 + sell_v2 round trip on mainnet.
WARNING: this submits real transactions and spends real funds. It exists to
cover the last gap the simulation scripts cannot reach — actual submission,
confirmation, and the post-trade accounting that parses a confirmed
transaction. Keep BUY_AMOUNT_SOL tiny.
Uses the bot's own PlatformAwareBuyer / PlatformAwareSeller, so a pass here
means the production path works end to end.
Usage:
uv run learning-examples/live_v2_round_trip.py # needs confirmation
uv run learning-examples/live_v2_round_trip.py --yes # skip the prompt
"""
import asyncio
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.pubkeys import LAMPORTS_PER_SOL # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
HOLD_SECONDS = 5
PRIORITY_FEE = 1_000_000
async def wait_for_token(timeout_seconds: float = 120.0) -> TokenInfo | None:
"""Wait for the bot's geyser listener to report a new coin.
Args:
timeout_seconds: How long to wait
Returns:
First TokenInfo seen, or None on timeout
"""
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(timeout_seconds / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0] if seen else None
async def main() -> int:
"""Run one live buy/sell round trip.
Returns:
Process exit code (0 if both legs confirmed)
"""
if "--yes" not in sys.argv:
print(
f"This spends real funds ({BUY_AMOUNT_SOL} SOL plus fees). "
f"Re-run with --yes to proceed."
)
return 2
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
try:
start_lamports = (await client.get_account_info(wallet.pubkey)).lamports
print(f"wallet: {wallet.pubkey}")
print(f"start balance: {start_lamports / LAMPORTS_PER_SOL:.9f} SOL\n")
print("Waiting for a fresh pump.fun coin...")
token_info = await wait_for_token()
if token_info is None:
print("No coin detected before timeout.")
return 2
print(f"detected: {token_info.symbol} ({token_info.mint})")
print(f"quote: {token_info.quote_mint}\n")
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=True,
)
seller = PlatformAwareSeller(
client, wallet, priority_fee_manager, slippage=0.3, max_retries=1
)
print("--- BUY (buy_v2) ---")
buy = await buyer.execute(token_info)
print(f"success={buy.success} tx={buy.tx_signature}")
if not buy.success:
print(f"error: {buy.error_message}")
return 1
print(f"tokens={buy.amount} price={buy.price:.10f}\n")
print(f"holding {HOLD_SECONDS}s...")
await asyncio.sleep(HOLD_SECONDS)
print("\n--- SELL (sell_v2) ---")
sell = await seller.execute(token_info, buy.amount, buy.price)
print(f"success={sell.success} tx={sell.tx_signature}")
if not sell.success:
print(f"error: {sell.error_message}")
end_lamports = (await client.get_account_info(wallet.pubkey)).lamports
delta = (end_lamports - start_lamports) / LAMPORTS_PER_SOL
print(f"\nend balance: {end_lamports / LAMPORTS_PER_SOL:.9f} SOL")
print(f"net change: {delta:+.9f} SOL")
print(
"\nNote: the base-token ATA still holds rent (~0.002 SOL) until a "
"cleanup run closes it."
)
return 0 if (buy.success and sell.success) else 1
finally:
await client.close()
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+85 -252
View File
@@ -3,28 +3,25 @@ import base64
import hashlib
import json
import os
import random
import struct
import base58
import pump_v2
import websockets
from construct import Flag, Int64ul, Struct
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction, VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -44,189 +41,59 @@ SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
SOL = Pubkey.from_string("So11111111111111111111111111111111111111112")
LAMPORTS_PER_SOL = 1_000_000_000
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
BREAKING_FEE_RECIPIENTS = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
Pubkey.from_string("3BpXnfJaUTiwXnJNe7Ej1rcbzqTTQUvLShZaWazebsVR"),
Pubkey.from_string("5cjcW9wExnJJiqgLjq7DEG75Pm6JBgE1hNv4B2vHXUW6"),
Pubkey.from_string("EHAAiTxcdDwQ3U4bU6YcMsQGaekdzLS3B5SmYo46kJtL"),
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
# RPC ENDPOINTS
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# logsSubscribe frames exceed the websockets library's 1 MiB default, which
# closes the connection with 1009 ("message too big").
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
# The bonding curve account and the v2 instruction layout live in pump_v2 so
# every example shares one copy. See learning-examples/pump_v2.py.
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
conn: AsyncClient, curve_address: Pubkey
) -> BondingCurveState:
) -> pump_v2.BondingCurveState:
"""Fetch and parse a bonding curve account.
Args:
conn: Solana RPC client
curve_address: Bonding curve address
Returns:
Parsed curve state
Raises:
ValueError: If the account is missing or not a bonding curve
"""
response = await conn.get_account_info(curve_address, encoding="base64")
if not response.value or not response.value.data:
raise ValueError("Invalid curve state: No data")
data = response.value.data
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(response.value.data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
def _find_creator_vault(creator: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(creator)],
PUMP_PROGRAM,
)
return derived_address
def _find_global_volume_accumulator() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"global_volume_accumulator"],
PUMP_PROGRAM,
)
return derived_address
def _find_user_volume_accumulator(user: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)],
PUMP_PROGRAM,
)
return derived_address
def _find_fee_config() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)],
PUMP_FEE_PROGRAM,
)
return derived_address
def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"bonding-curve-v2", bytes(mint)],
PUMP_PROGRAM,
)
return derived_address
async def get_fee_recipient(
client: AsyncClient, curve_state: BondingCurveState
) -> Pubkey:
"""Determine the correct fee recipient based on mayhem mode.
Mayhem mode tokens use a different fee recipient (reserved_fee_recipient from Global account)
instead of the standard fee recipient. This function checks the bonding curve state
and returns the appropriate fee recipient.
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole quote units.
Args:
client: Solana RPC client to fetch Global account data
curve_state: Parsed bonding curve state containing is_mayhem_mode flag
curve_state: Parsed curve state
Returns:
Appropriate fee recipient pubkey (mayhem or standard)
Price in the curve's quote asset
Raises:
ValueError: If reserves are empty
"""
if not curve_state.is_mayhem_mode:
return PUMP_FEE
# Fetch Global account to get reserved_fee_recipient for mayhem mode tokens
response = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
if not response.value or not response.value.data:
# Fallback to standard fee if Global account cannot be fetched
return PUMP_FEE
data = response.value.data
# Parse reserved_fee_recipient from Global account
# Offset calculation based on pump_fun_idl.json Global struct:
# discriminator(8) + initialized(1) + authority(32) + fee_recipient(32) +
# initial_virtual_token_reserves(8) + initial_virtual_sol_reserves(8) +
# initial_real_token_reserves(8) + token_total_supply(8) + fee_basis_points(8) +
# withdraw_authority(32) + enable_migrate(1) + pool_migration_fee(8) +
# creator_fee_basis_points(8) + fee_recipients[7](224) + set_creator_authority(32) +
# admin_set_creator_authority(32) + create_v2_enabled(1) + whitelist_pda(32) = 483
RESERVED_FEE_RECIPIENT_OFFSET = 483
if len(data) < RESERVED_FEE_RECIPIENT_OFFSET + 32:
# Fallback if account data is too short
return PUMP_FEE
reserved_fee_recipient_bytes = data[
RESERVED_FEE_RECIPIENT_OFFSET : RESERVED_FEE_RECIPIENT_OFFSET + 32
]
return Pubkey.from_bytes(reserved_fee_recipient_bytes)
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return price
async def buy_token(
@@ -243,97 +110,54 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program_id=token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state to calculate price and determine fee recipient
# Fetch bonding curve state for price, mayhem mode and quote asset.
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
# Amounts are denominated in the curve's quote asset, which is not
# necessarily SOL any more.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
token_amount = amount / token_price_sol
max_quote_cost = int(amount * quote_unit * (1 + slippage))
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
print(f"Quote asset: {quote_mint}")
print(f"Buying {token_amount:.6f} tokens, max cost {max_quote_cost} raw units")
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=max_quote_cost,
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
instructions = [
set_compute_unit_price(1_000),
create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
),
]
# SOL-paired coins settle in native SOL and only seed-check the quote
# ATA, so creating it would waste rent. Other quotes need a real account.
if not pump_v2.is_sol_paired(quote_mint):
instructions.append(
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
quote_mint,
token_program_id=pump_v2.quote_token_program(quote_mint),
)
)
instructions.append(buy_ix)
discriminator = struct.pack("<Q", 16927863322537952870)
# Encode OptionBool for track_volume: [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1])
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ track_volume_bytes
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
)
msg = Message(
[set_compute_unit_price(1_000), idempotent_ata_ix, buy_ix], payer.pubkey()
)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
@@ -416,7 +240,9 @@ async def listen_for_create_transaction():
create_discriminator = calculate_discriminator("global:create")
create_v2_discriminator = calculate_discriminator("global:create_v2")
async with websockets.connect(RPC_WEBSOCKET) as websocket:
async with websockets.connect(
RPC_WEBSOCKET, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
subscription_message = json.dumps(
{
"jsonrpc": "2.0",
@@ -489,8 +315,13 @@ async def listen_for_create_transaction():
# Skip txs that use Address Lookup Tables — their
# instruction account indices reference ALT-loaded keys
# not present in transaction.message.account_keys.
static_keys = transaction.message.account_keys
if any(idx >= len(static_keys) for idx in ix.accounts):
static_keys = (
transaction.message.account_keys
)
if any(
idx >= len(static_keys)
for idx in ix.accounts
):
continue
account_keys = [
str(static_keys[index])
@@ -524,7 +355,9 @@ async def main():
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+52 -144
View File
@@ -22,28 +22,26 @@ import base64
import hashlib
import json
import os
import random
import struct
import base58
import pump_v2
import websockets
from construct import Flag, Int64ul, Struct
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.instruction import Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction, VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Discriminators
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -83,61 +81,12 @@ COMPUTE_BUDGET_PROGRAM = Pubkey.from_string(
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# logsSubscribe frames exceed the websockets library's 1 MiB default, which
# closes the connection with 1009 ("message too big").
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -151,16 +100,25 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
curve_state: Parsed curve state
Returns:
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
def _find_creator_vault(creator: Pubkey) -> Pubkey:
@@ -281,89 +239,30 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program_id=token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state to calculate price and determine fee recipient
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
token_amount = amount / token_price_sol
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
discriminator = struct.pack("<Q", 16927863322537952870)
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ struct.pack("<B", 1) # track_volume: 1 = true (enable volume tracking)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
print(f"Quote asset: {quote_mint}")
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=int(amount * quote_unit * (1 + slippage)),
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
)
@@ -458,7 +357,9 @@ async def listen_for_create_transaction():
create_discriminator = calculate_discriminator("global:create")
create_v2_discriminator = calculate_discriminator("global:create_v2")
async with websockets.connect(RPC_WEBSOCKET) as websocket:
async with websockets.connect(
RPC_WEBSOCKET, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
subscription_message = json.dumps(
{
"jsonrpc": "2.0",
@@ -531,8 +432,13 @@ async def listen_for_create_transaction():
# Skip txs that use Address Lookup Tables — their
# instruction account indices reference ALT-loaded keys
# not present in transaction.message.account_keys.
static_keys = transaction.message.account_keys
if any(idx >= len(static_keys) for idx in ix.accounts):
static_keys = (
transaction.message.account_keys
)
if any(
idx >= len(static_keys)
for idx in ix.accounts
):
continue
account_keys = [
str(
@@ -569,7 +475,9 @@ async def main():
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+42 -144
View File
@@ -1,25 +1,22 @@
import asyncio
import json
import os
import random
import struct
import sys
import base58
import grpc
from construct import Flag, Int64ul, Struct
import pump_v2
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -29,7 +26,7 @@ from src.geyser.generated import (
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -76,60 +73,7 @@ PUMP_CREATE_DISCRIMINATOR = struct.pack("<Q", 8576854823835016728)
PUMP_CREATE_V2_DISCRIMINATOR = bytes([214, 144, 76, 236, 95, 139, 49, 180])
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -143,16 +87,25 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
curve_state: Parsed curve state
Returns:
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
def _find_creator_vault(creator: Pubkey) -> Pubkey:
@@ -380,87 +333,28 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state for price + mayhem-mode-aware fee recipient.
# Fetch bonding curve state for price, mayhem mode and quote asset.
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
token_amount = amount / token_price_sol
fee_recipient = await get_fee_recipient(client, curve_state)
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
discriminator = struct.pack("<Q", 16927863322537952870)
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ struct.pack("<B", 1) # track_volume: 1 = true (enable volume tracking)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
print(f"Quote asset: {quote_mint}")
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=int(amount * quote_unit * (1 + slippage)),
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program
)
@@ -519,13 +413,17 @@ async def main():
print(json.dumps(token_data, indent=2))
sleep_duration_sec = 15
print(f"Waiting {sleep_duration_sec}s for the bonding curve account to propagate...")
print(
f"Waiting {sleep_duration_sec}s for the bonding curve account to propagate..."
)
await asyncio.sleep(sleep_duration_sec)
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+55 -238
View File
@@ -1,26 +1,28 @@
import asyncio
import os
import random
import struct
import sys
import base58
from construct import Flag, Int64ul, Struct
import pump_v2
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from spl.token.instructions import get_associated_token_address
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1] or hardcode here
TOKEN_MINT = Pubkey.from_string(
sys.argv[1] if len(sys.argv) > 1 else "..."
) # Pass mint as argv[1] or hardcode here
# Global constants
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
@@ -44,82 +46,8 @@ UNIT_BUDGET = 100_000
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
BREAKING_FEE_RECIPIENTS = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
Pubkey.from_string("3BpXnfJaUTiwXnJNe7Ej1rcbzqTTQUvLShZaWazebsVR"),
Pubkey.from_string("5cjcW9wExnJJiqgLjq7DEG75Pm6JBgE1hNv4B2vHXUW6"),
Pubkey.from_string("EHAAiTxcdDwQ3U4bU6YcMsQGaekdzLS3B5SmYo46kJtL"),
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
offset += 1
else:
self.is_mayhem_mode = False
# Parse cashback flag if bytes remaining (added in V4 — late-Feb 2026 cashback upgrade)
if len(data) >= offset + 1:
self.is_cashback_coin = bool(data[offset])
else:
self.is_cashback_coin = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -133,7 +61,7 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def get_bonding_curve_address(mint: Pubkey) -> tuple[Pubkey, int]:
@@ -162,84 +90,22 @@ def find_creator_vault(creator: Pubkey) -> Pubkey:
return derived_address
def _find_fee_config() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)],
PUMP_FEE_PROGRAM,
)
return derived_address
def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"bonding-curve-v2", bytes(mint)],
PUMP_PROGRAM,
)
return derived_address
def _find_user_volume_accumulator(user: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)],
PUMP_PROGRAM,
)
return derived_address
async def get_fee_recipient(
client: AsyncClient, curve_state: BondingCurveState
) -> Pubkey:
"""Determine the correct fee recipient based on mayhem mode.
Mayhem mode tokens use a different fee recipient (reserved_fee_recipient from Global account)
instead of the standard fee recipient. This function checks the bonding curve state
and returns the appropriate fee recipient.
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
client: Solana RPC client to fetch Global account data
curve_state: Parsed bonding curve state containing is_mayhem_mode flag
curve_state: Parsed curve state
Returns:
Appropriate fee recipient pubkey (mayhem or standard)
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
if not curve_state.is_mayhem_mode:
return PUMP_FEE
# Fetch Global account to get reserved_fee_recipient for mayhem mode tokens
response = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
if not response.value or not response.value.data:
# Fallback to standard fee if Global account cannot be fetched
return PUMP_FEE
data = response.value.data
# Parse reserved_fee_recipient from Global account
# Offset calculation based on pump_fun_idl.json Global struct:
# discriminator(8) + initialized(1) + authority(32) + fee_recipient(32) +
# initial_virtual_token_reserves(8) + initial_virtual_sol_reserves(8) +
# initial_real_token_reserves(8) + token_total_supply(8) + fee_basis_points(8) +
# withdraw_authority(32) + enable_migrate(1) + pool_migration_fee(8) +
# creator_fee_basis_points(8) + fee_recipients[7](224) + set_creator_authority(32) +
# admin_set_creator_authority(32) + create_v2_enabled(1) + whitelist_pda(32) = 483
RESERVED_FEE_RECIPIENT_OFFSET = 483
if len(data) < RESERVED_FEE_RECIPIENT_OFFSET + 32:
# Fallback if account data is too short
return PUMP_FEE
reserved_fee_recipient_bytes = data[
RESERVED_FEE_RECIPIENT_OFFSET : RESERVED_FEE_RECIPIENT_OFFSET + 32
]
return Pubkey.from_bytes(reserved_fee_recipient_bytes)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
async def get_token_balance(conn: AsyncClient, associated_token_account: Pubkey):
@@ -298,97 +164,48 @@ async def sell_token(
token_price_sol = calculate_pump_curve_price(curve_state)
print(f"Price per Token: {token_price_sol:.20f} SOL")
# Calculate minimum SOL output
# Minimum payout, in the curve's quote asset raw units.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
amount = token_balance
min_sol_output = float(token_balance_decimal) * float(token_price_sol)
slippage_factor = 1 - slippage
min_sol_output = int((min_sol_output * slippage_factor) * LAMPORTS_PER_SOL)
expected_output = float(token_balance_decimal) * float(token_price_sol)
min_quote_output = max(1, int(expected_output * (1 - slippage) * quote_unit))
print(f"Selling {token_balance_decimal} tokens")
print(f"Minimum SOL output: {min_sol_output / LAMPORTS_PER_SOL:.10f} SOL")
print(f"Quote asset: {quote_mint}")
print(
f"Minimum output: {min_quote_output / quote_unit:.10f} ({min_quote_output} raw)"
)
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
# sell_v2 takes the same 26 mandatory accounts for every coin — no
# cashback/mayhem branching on the account list any more.
sell_ix = pump_v2.build_sell_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=amount,
min_quote_output_raw=min_quote_output,
quote_mint=quote_mint,
base_token_program=token_program_id,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=creator_vault,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=token_program_id, is_signer=False, is_writable=False
), # Use dynamic token_program_id
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
# Index 12: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 13: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
]
# For cashback coins, insert user_volume_accumulator before bonding-curve-v2 (17 accounts total).
if curve_state.is_cashback_coin:
accounts.append(
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
instructions = [set_compute_unit_price(1_000)]
# Non-SOL proceeds land in the seller's quote ATA, which must exist.
if not pump_v2.is_sol_paired(quote_mint):
instructions.append(
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
quote_mint,
token_program_id=pump_v2.quote_token_program(quote_mint),
)
)
accounts.extend([
# bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# Breaking-upgrade fee recipient (mutable) — required from 2026-04-28.
# 16 accounts non-cashback / 17 accounts cashback.
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
])
instructions.append(sell_ix)
discriminator = struct.pack("<Q", 12502976635542562355)
# Encode OptionBool for track_volume: [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1])
data = (
discriminator
+ struct.pack("<Q", amount)
+ struct.pack("<Q", min_sol_output)
+ track_volume_bytes
)
sell_ix = Instruction(PUMP_PROGRAM, data, accounts)
msg = Message([set_compute_unit_price(1_000), sell_ix], payer.pubkey())
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
# Continue with the sell transaction
+87 -63
View File
@@ -1,10 +1,10 @@
import asyncio
import os
import random
import struct
from typing import Final
import base58
import pump_v2
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
@@ -166,6 +166,32 @@ def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
return derived_address
async def assert_transaction_succeeded(client: AsyncClient, signature) -> None:
"""Raise if a confirmed transaction actually failed on-chain.
`confirm_transaction` only waits for the transaction to land — a landed
transaction can still have reverted. Without this check a failed buy prints
as a success, which is exactly how a wrong fee recipient (NotAuthorized,
6000) can look like a passing test.
Args:
client: Solana RPC client
signature: Transaction signature to inspect
Raises:
RuntimeError: If the transaction reverted
"""
result = await client.get_transaction(
signature, commitment="confirmed", max_supported_transaction_version=0
)
value = result.value
if value is None:
raise RuntimeError(f"Transaction {signature} not found after confirmation")
err = value.transaction.meta.err if value.transaction.meta else None
if err:
raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}")
def create_pump_create_instruction(
mint: Pubkey,
mint_authority: Pubkey,
@@ -251,68 +277,46 @@ def create_buy_instruction(
token_amount: int,
max_sol_cost: int,
track_volume: bool = True,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Create the buy instruction."""
accounts = [
AccountMeta(pubkey=global_state, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_user, is_signer=False, is_writable=True),
AccountMeta(pubkey=user, is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=SYSTEM_TOKEN_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(), is_signer=False, is_writable=False
),
AccountMeta(
pubkey=_find_user_volume_accumulator(user),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
"""Create the buy instruction (buy_v2).
# Encode OptionBool for track_volume
# OptionBool: [0] = None, [1, 0] = Some(false), [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1 if track_volume else 0])
The signature is unchanged for callers, but this builds `buy_v2` with its 27
mandatory accounts. Several parameters are accepted only for backwards
compatibility and are derived or dropped internally: buy_v2 takes no
track_volume argument, and pump_v2 selects the fee recipient from the
documented set. This script mints the coin with `creator = payer`, so the
buyer is also the creator.
data = (
BUY_DISCRIMINATOR
+ struct.pack("<Q", token_amount)
+ struct.pack("<Q", max_sol_cost)
+ track_volume_bytes
Args:
global_state: Unused; pump_v2 uses the canonical global PDA
fee_recipient: Unused; pump_v2 selects from the documented set
mint: Base token mint just created
bonding_curve: Unused; derived from the mint
associated_bonding_curve: Unused; derived
associated_user: Unused; derived
user: Buyer, and the coin's creator in this script
creator_vault: Unused; derived from the creator
token_amount: Base tokens to buy, raw units
max_sol_cost: Spend cap in lamports
track_volume: Ignored; volume tracking is unconditional under buy_v2
is_mayhem_mode: Mayhem coins must use a *reserved* fee recipient; passing
this wrong makes the program reject the buy with NotAuthorized (6000)
Returns:
The buy_v2 instruction
"""
return pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=user,
user=user,
token_amount_raw=token_amount,
max_quote_cost_raw=max_sol_cost,
quote_mint=pump_v2.WSOL_MINT,
is_mayhem_mode=is_mayhem_mode,
base_token_program=SYSTEM_TOKEN_PROGRAM,
)
return Instruction(PUMP_PROGRAM, data, accounts)
async def main():
"""Create and buy pump.fun token in a single transaction."""
@@ -385,14 +389,19 @@ async def main():
bonding_curve=bonding_curve,
user=payer.pubkey(),
),
# Create user ATA
]
# buy_v2's 27 accounts push a combined create+buy message past Solana's
# 1232-byte packet limit (measured 1972 bytes for the v2 pair), so the buy
# goes in a second transaction. The legacy 18-account buy used to fit;
# recovering atomicity would need an address lookup table.
buy_instructions = [
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
mint_keypair.pubkey(),
SYSTEM_TOKEN_PROGRAM,
),
# Buy tokens
create_buy_instruction(
global_state=PUMP_GLOBAL,
fee_recipient=PUMP_FEE,
@@ -416,18 +425,33 @@ async def main():
[payer, mint_keypair], message, recent_blockhash.value.blockhash
)
print("\nSending transaction...")
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
tx_hash = response.value
print(f"Transaction sent: https://solscan.io/tx/{tx_hash}")
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
print("Transaction confirmed!")
await assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
return tx_hash
+91 -65
View File
@@ -1,10 +1,10 @@
import asyncio
import os
import random
import struct
from typing import Final
import base58
import pump_v2
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
@@ -283,6 +283,32 @@ def create_extend_account_instruction(
return Instruction(PUMP_PROGRAM, data, accounts)
async def assert_transaction_succeeded(client: AsyncClient, signature) -> None:
"""Raise if a confirmed transaction actually failed on-chain.
`confirm_transaction` only waits for the transaction to land — a landed
transaction can still have reverted. Without this check a failed buy prints
as a success, which is exactly how a wrong fee recipient (NotAuthorized,
6000) can look like a passing test.
Args:
client: Solana RPC client
signature: Transaction signature to inspect
Raises:
RuntimeError: If the transaction reverted
"""
result = await client.get_transaction(
signature, commitment="confirmed", max_supported_transaction_version=0
)
value = result.value
if value is None:
raise RuntimeError(f"Transaction {signature} not found after confirmation")
err = value.transaction.meta.err if value.transaction.meta else None
if err:
raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}")
def create_buy_instruction(
global_state: Pubkey,
fee_recipient: Pubkey,
@@ -295,68 +321,49 @@ def create_buy_instruction(
token_amount: int,
max_sol_cost: int,
track_volume: bool = True,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Create the buy instruction."""
accounts = [
AccountMeta(pubkey=global_state, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_user, is_signer=False, is_writable=True),
AccountMeta(pubkey=user, is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(), is_signer=False, is_writable=False
),
AccountMeta(
pubkey=_find_user_volume_accumulator(user),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
"""Create the buy instruction (buy_v2).
# Encode OptionBool for track_volume
# OptionBool: [0] = None, [1, 0] = Some(false), [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1 if track_volume else 0])
Signature is unchanged for the caller, but this now builds `buy_v2` with its
27 mandatory accounts. `global_state`, `fee_recipient`,
`associated_bonding_curve`, `associated_user`, `creator_vault` and
`track_volume` are accepted for backwards compatibility and derived or
dropped internally — buy_v2 has no track_volume argument, and pump_v2 picks
the fee recipient from the documented set.
data = (
BUY_DISCRIMINATOR
+ struct.pack("<Q", token_amount)
+ struct.pack("<Q", max_sol_cost)
+ track_volume_bytes
These scripts mint the coin themselves with `creator = payer`, so the buyer
is also the creator.
Args:
global_state: Unused; pump_v2 uses the canonical global PDA
fee_recipient: Unused; pump_v2 selects from the documented set
mint: Base token mint just created
bonding_curve: Unused; derived from the mint
associated_bonding_curve: Unused; derived
associated_user: Unused; derived
user: Buyer, and the coin's creator in these scripts
creator_vault: Unused; derived from the creator
token_amount: Base tokens to buy, raw units
max_sol_cost: Spend cap in lamports
track_volume: Ignored; volume tracking is unconditional under buy_v2
is_mayhem_mode: Mayhem coins must use a *reserved* fee recipient; passing
this wrong makes the program reject the buy with NotAuthorized (6000)
Returns:
The buy_v2 instruction
"""
return pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=user,
user=user,
token_amount_raw=token_amount,
max_quote_cost_raw=max_sol_cost,
quote_mint=pump_v2.WSOL_MINT,
is_mayhem_mode=is_mayhem_mode,
base_token_program=TOKEN_2022_PROGRAM,
)
return Instruction(PUMP_PROGRAM, data, accounts)
async def get_fee_recipient_for_mayhem(client: AsyncClient, is_mayhem: bool) -> Pubkey:
"""Get the appropriate fee recipient based on mayhem mode.
@@ -474,14 +481,19 @@ async def main():
bonding_curve=bonding_curve,
user=payer.pubkey(),
),
# Create user ATA
]
# buy_v2's 27 accounts push a combined create+buy message past Solana's
# 1232-byte packet limit (measured 1972 bytes), so the buy goes in a
# second transaction. The legacy 18-account buy used to fit; recovering
# atomicity would need an address lookup table.
buy_instructions = [
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
mint_keypair.pubkey(),
TOKEN_2022_PROGRAM,
),
# Buy tokens
create_buy_instruction(
global_state=PUMP_GLOBAL,
fee_recipient=fee_recipient,
@@ -494,6 +506,7 @@ async def main():
token_amount=expected_tokens,
max_sol_cost=max_sol_cost,
track_volume=True,
is_mayhem_mode=ENABLE_MAYHEM_MODE,
),
]
@@ -503,18 +516,31 @@ async def main():
[payer, mint_keypair], message, recent_blockhash.value.blockhash
)
print("\nSending transaction...")
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
tx_hash = response.value
print(f"Transaction sent: https://solscan.io/tx/{tx_hash}")
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
print("Transaction confirmed!")
await assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
return tx_hash
+502
View File
@@ -0,0 +1,502 @@
"""Self-contained pump.fun v2 trade helpers for the learning examples.
The bonding-curve program's `buy_v2` / `sell_v2` instructions take 27 and 26
mandatory accounts in a fixed order, identical for every coin — SOL-paired or
USDC-paired, mayhem or not, cashback or not. Encoding that once here keeps the
example scripts from each carrying their own copy of the list, which is how they
drift out of sync with the program.
Deliberately standalone: it imports nothing from `src/`, so the examples stay
readable on their own. `learning-examples/verify_v2_account_layout.py` checks the
layout below against `idl/pump_fun_idl.json`.
Docs: BUY.md, SELL.md and COIN_CREATION.md under docs/instructions in
github.com/pump-fun/pump-public-docs
"""
import secrets
import struct
from construct import Flag, Int64ul, Struct
from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
# Programs and well-known accounts
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
PUMP_GLOBAL = Pubkey.from_string("4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf")
PUMP_EVENT_AUTHORITY = Pubkey.from_string(
"Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1"
)
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
ASSOCIATED_TOKEN_PROGRAM = Pubkey.from_string(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
)
# Quote mints. bonding_curve.quote_mint is Pubkey::default() for SOL-paired
# coins, but the v2 instructions expect wrapped SOL to be passed explicitly.
DEFAULT_PUBKEY = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
USDC_MINT = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
QUOTE_DECIMALS = {WSOL_MINT: 9, USDC_MINT: 6}
TOKEN_DECIMALS = 6
LAMPORTS_PER_SOL = 1_000_000_000
# Account discriminator for the BondingCurve account.
BONDING_CURVE_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
# Instruction discriminators (first 8 bytes of sha256("global:<name>")).
BUY_V2_DISCRIMINATOR = bytes([184, 23, 238, 97, 103, 197, 211, 61])
SELL_V2_DISCRIMINATOR = bytes([93, 246, 130, 60, 231, 233, 64, 178])
# Fee recipients: 8 normal (non-mayhem coins), 8 reserved (mayhem coins),
# 8 buyback (every coin). See FEE_RECIPIENTS.md in the pump-fun public docs.
NORMAL_FEE_RECIPIENTS = [
Pubkey.from_string("62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV"),
Pubkey.from_string("7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"),
Pubkey.from_string("7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX"),
Pubkey.from_string("9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz"),
Pubkey.from_string("AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY"),
Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM"),
Pubkey.from_string("FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz"),
Pubkey.from_string("G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP"),
]
RESERVED_FEE_RECIPIENTS = [
Pubkey.from_string("GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS"),
Pubkey.from_string("4budycTjhs9fD6xw62VBducVTNgMgJJ5BgtKq7mAZwn6"),
Pubkey.from_string("8SBKzEQU4nLSzcwF4a74F2iaUDQyTfjGndn6qUWBnrpR"),
Pubkey.from_string("4UQeTP1T39KZ9Sfxzo3WR5skgsaP6NZa87BAkuazLEKH"),
Pubkey.from_string("8sNeir4QsLsJdYpc9RZacohhK1Y5FLU3nC5LXgYB4aa6"),
Pubkey.from_string("Fh9HmeLNUMVCvejxCtCL2DbYaRyBFVJ5xrWkLnMH6fdk"),
Pubkey.from_string("463MEnMeGyJekNZFQSTUABBEbLnvMTALbT6ZmsxAbAdq"),
Pubkey.from_string("6AUH3WEHucYZyC61hqpqYUWVto5qA5hjHuNQ32GNnNxA"),
]
BUYBACK_FEE_RECIPIENTS = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
Pubkey.from_string("3BpXnfJaUTiwXnJNe7Ej1rcbzqTTQUvLShZaWazebsVR"),
Pubkey.from_string("5cjcW9wExnJJiqgLjq7DEG75Pm6JBgE1hNv4B2vHXUW6"),
Pubkey.from_string("EHAAiTxcdDwQ3U4bU6YcMsQGaekdzLS3B5SmYo46kJtL"),
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
def normalize_quote_mint(quote_mint: Pubkey | None) -> Pubkey:
"""Map a curve's raw quote_mint onto the mint to pass to the instruction.
Args:
quote_mint: Raw value read from the bonding curve, or None
Returns:
Wrapped SOL for SOL-paired coins, otherwise the mint unchanged
"""
if quote_mint is None or quote_mint == DEFAULT_PUBKEY:
return WSOL_MINT
return quote_mint
def is_sol_paired(quote_mint: Pubkey | None) -> bool:
"""Whether a coin settles in native SOL.
Args:
quote_mint: Raw or normalized quote mint
Returns:
True if the coin is SOL-paired
"""
return normalize_quote_mint(quote_mint) == WSOL_MINT
def quote_units(quote_mint: Pubkey) -> int:
"""Raw units per whole unit of a quote mint.
Args:
quote_mint: Quote mint address
Returns:
1e9 for SOL, 1e6 for USDC
"""
return 10 ** QUOTE_DECIMALS.get(quote_mint, 9)
def quote_token_program(quote_mint: Pubkey) -> Pubkey:
"""Token program owning a quote mint.
Args:
quote_mint: Quote mint address
Returns:
Token program id (both current quote mints are legacy SPL Token)
"""
return TOKEN_PROGRAM
def find_bonding_curve(mint: Pubkey) -> Pubkey:
"""Derive the bonding curve PDA.
Args:
mint: Base token mint
Returns:
Bonding curve address
"""
return Pubkey.find_program_address([b"bonding-curve", bytes(mint)], PUMP_PROGRAM)[0]
def find_creator_vault(creator: Pubkey) -> Pubkey:
"""Derive the creator vault PDA.
Args:
creator: Coin creator
Returns:
Creator vault address
"""
return Pubkey.find_program_address(
[b"creator-vault", bytes(creator)], PUMP_PROGRAM
)[0]
def find_global_volume_accumulator() -> Pubkey:
"""Derive the global volume accumulator PDA.
Returns:
Global volume accumulator address
"""
return Pubkey.find_program_address([b"global_volume_accumulator"], PUMP_PROGRAM)[0]
def find_user_volume_accumulator(user: Pubkey) -> Pubkey:
"""Derive a user's volume accumulator PDA.
Args:
user: User wallet
Returns:
User volume accumulator address
"""
return Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)], PUMP_PROGRAM
)[0]
def find_fee_config() -> Pubkey:
"""Derive the fee config PDA (under the pump fees program).
Returns:
Fee config address
"""
return Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)], PUMP_FEE_PROGRAM
)[0]
def find_sharing_config(base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA (under the pump fees program).
Mandatory on buy_v2/sell_v2.
Args:
base_mint: Base token mint
Returns:
Sharing config address
"""
return Pubkey.find_program_address(
[b"sharing-config", bytes(base_mint)], PUMP_FEE_PROGRAM
)[0]
def find_associated_token_account(
owner: Pubkey, mint: Pubkey, token_program: Pubkey
) -> Pubkey:
"""Derive an associated token account address.
Args:
owner: ATA owner (may be a PDA)
mint: Token mint
token_program: Token program owning the mint
Returns:
Associated token account address
"""
return Pubkey.find_program_address(
[bytes(owner), bytes(token_program), bytes(mint)],
ASSOCIATED_TOKEN_PROGRAM,
)[0]
def pick_fee_recipient(*, is_mayhem_mode: bool) -> Pubkey:
"""Pick a fee_recipient from the set the program expects for this coin.
Args:
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
A fee recipient address
"""
pool = RESERVED_FEE_RECIPIENTS if is_mayhem_mode else NORMAL_FEE_RECIPIENTS
return secrets.choice(pool)
def pick_buyback_fee_recipient() -> Pubkey:
"""Pick a buyback fee recipient, required on every v2 trade.
Returns:
A buyback fee recipient address
"""
return secrets.choice(BUYBACK_FEE_RECIPIENTS)
class BondingCurveState:
"""Parsed pump.fun BondingCurve account.
The account is 151 bytes: the 115-byte documented struct followed by
reserved padding. The SOL-named reserve fields were renamed to quote fields
when non-SOL quote assets landed; the old names are kept as aliases.
"""
_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_quote_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_quote_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
# Byte offsets from the start of the account, discriminator included.
_CREATOR_OFFSET = 49
_MAYHEM_OFFSET = 81
_CASHBACK_OFFSET = 82
_QUOTE_MINT_OFFSET = 83
def __init__(self, data: bytes) -> None:
"""Parse bonding curve account data.
Args:
data: Raw account data including the 8-byte discriminator
Raises:
ValueError: If the discriminator is wrong or the data is truncated
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != BONDING_CURVE_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
self.__dict__.update(self._STRUCT.parse(data[8:]))
# Aliases for the pre-rename field names.
self.virtual_sol_reserves = self.virtual_quote_reserves
self.real_sol_reserves = self.real_quote_reserves
self.creator = self._read_pubkey(data, self._CREATOR_OFFSET)
self.is_mayhem_mode = self._read_flag(data, self._MAYHEM_OFFSET)
self.is_cashback_coin = self._read_flag(data, self._CASHBACK_OFFSET)
raw_quote_mint = self._read_pubkey(data, self._QUOTE_MINT_OFFSET)
self.quote_mint = normalize_quote_mint(raw_quote_mint)
self.is_sol_paired = is_sol_paired(raw_quote_mint)
@staticmethod
def _read_pubkey(data: bytes, offset: int) -> Pubkey | None:
"""Read a 32-byte pubkey if the data extends that far.
Args:
data: Raw account data
offset: Byte offset
Returns:
Pubkey, or None if the field is absent
"""
if len(data) < offset + 32:
return None
return Pubkey.from_bytes(data[offset : offset + 32])
@staticmethod
def _read_flag(data: bytes, offset: int) -> bool:
"""Read a single-byte bool if the data extends that far.
Args:
data: Raw account data
offset: Byte offset
Returns:
Flag value, or False if the field is absent
"""
return bool(data[offset]) if len(data) > offset else False
def price_per_token(self) -> float:
"""Current price in whole quote units per whole token.
Returns:
Price, or 0.0 if reserves are empty
"""
if not self.virtual_token_reserves or not self.virtual_quote_reserves:
return 0.0
return (
self.virtual_quote_reserves / 10 ** QUOTE_DECIMALS.get(self.quote_mint, 9)
) / (self.virtual_token_reserves / 10**TOKEN_DECIMALS)
def build_v2_accounts(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
quote_mint: Pubkey,
base_token_program: Pubkey,
is_mayhem_mode: bool,
include_global_volume_accumulator: bool,
) -> list[AccountMeta]:
"""Build the ordered account list shared by buy_v2 and sell_v2.
Args:
base_mint: Coin being traded
creator: Coin creator, from bonding_curve.creator
user: Signer / trader
quote_mint: Normalized quote mint (wrapped SOL for SOL-paired coins)
base_token_program: Token program owning base_mint
is_mayhem_mode: Selects which fee recipient set to draw from
include_global_volume_accumulator: True for buy_v2, False for sell_v2
Returns:
Ordered AccountMeta list (27 entries for buy_v2, 26 for sell_v2)
"""
quote_program = quote_token_program(quote_mint)
bonding_curve = find_bonding_curve(base_mint)
creator_vault = find_creator_vault(creator)
user_volume_accumulator = find_user_volume_accumulator(user)
fee_recipient = pick_fee_recipient(is_mayhem_mode=is_mayhem_mode)
buyback_fee_recipient = pick_buyback_fee_recipient()
def ata(owner: Pubkey, mint: Pubkey, program: Pubkey) -> Pubkey:
return find_associated_token_account(owner, mint, program)
accounts = [
(PUMP_GLOBAL, False),
(base_mint, False),
(quote_mint, False),
(base_token_program, False),
(quote_program, False),
(ASSOCIATED_TOKEN_PROGRAM, False),
(fee_recipient, True),
(ata(fee_recipient, quote_mint, quote_program), True),
(buyback_fee_recipient, True),
(ata(buyback_fee_recipient, quote_mint, quote_program), True),
(bonding_curve, True),
(ata(bonding_curve, base_mint, base_token_program), True),
(ata(bonding_curve, quote_mint, quote_program), True),
(user, True),
(ata(user, base_mint, base_token_program), True),
(ata(user, quote_mint, quote_program), True),
(creator_vault, True),
(ata(creator_vault, quote_mint, quote_program), True),
(find_sharing_config(base_mint), False),
]
if include_global_volume_accumulator:
accounts.append((find_global_volume_accumulator(), False))
accounts += [
(user_volume_accumulator, True),
(ata(user_volume_accumulator, quote_mint, quote_program), True),
(find_fee_config(), False),
(PUMP_FEE_PROGRAM, False),
(SYSTEM_PROGRAM, False),
(PUMP_EVENT_AUTHORITY, False),
(PUMP_PROGRAM, False),
]
return [
AccountMeta(pubkey=pubkey, is_signer=pubkey == user, is_writable=writable)
for pubkey, writable in accounts
]
def build_buy_v2_instruction(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
token_amount_raw: int,
max_quote_cost_raw: int,
quote_mint: Pubkey = WSOL_MINT,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Build a buy_v2 instruction.
Args:
base_mint: Coin to buy
creator: Coin creator, from bonding_curve.creator
user: Buyer / signer
token_amount_raw: Base tokens to buy, in raw units
max_quote_cost_raw: Spend cap in the quote mint's raw units
quote_mint: Normalized quote mint
base_token_program: Token program owning base_mint
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
The buy_v2 instruction
"""
return Instruction(
program_id=PUMP_PROGRAM,
data=BUY_V2_DISCRIMINATOR
+ struct.pack("<Q", token_amount_raw)
+ struct.pack("<Q", max_quote_cost_raw),
accounts=build_v2_accounts(
base_mint=base_mint,
creator=creator,
user=user,
quote_mint=quote_mint,
base_token_program=base_token_program,
is_mayhem_mode=is_mayhem_mode,
include_global_volume_accumulator=True,
),
)
def build_sell_v2_instruction(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
token_amount_raw: int,
min_quote_output_raw: int,
quote_mint: Pubkey = WSOL_MINT,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Build a sell_v2 instruction.
Args:
base_mint: Coin to sell
creator: Coin creator, from bonding_curve.creator
user: Seller / signer
token_amount_raw: Base tokens to sell, in raw units
min_quote_output_raw: Minimum acceptable payout in raw quote units
quote_mint: Normalized quote mint
base_token_program: Token program owning base_mint
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
The sell_v2 instruction
"""
return Instruction(
program_id=PUMP_PROGRAM,
data=SELL_V2_DISCRIMINATOR
+ struct.pack("<Q", token_amount_raw)
+ struct.pack("<Q", min_quote_output_raw),
accounts=build_v2_accounts(
base_mint=base_mint,
creator=creator,
user=user,
quote_mint=quote_mint,
base_token_program=base_token_program,
is_mayhem_mode=is_mayhem_mode,
include_global_volume_accumulator=False,
),
)
@@ -100,7 +100,11 @@ BREAKING_FEE_RECIPIENTS = [
POOL_DISCRIMINATOR_SIZE = 8
POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data
POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored
POOL_IS_CASHBACK_OFFSET = 244 # Where is_cashback_coin flag is stored
POOL_IS_CASHBACK_OFFSET = 244
# virtual_quote_reserves is an i128 appended after the flags. Pool fields
# end at 261; live accounts are 301 bytes with trailing padding.
POOL_VIRTUAL_QUOTE_RESERVES_OFFSET = 245
POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
@@ -188,6 +192,11 @@ async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
("pool_quote_token_account", "pubkey"),
("lp_supply", "u64"),
("coin_creator", "pubkey"),
# Appended after coin_creator: is_mayhem_mode (243), is_cashback_coin
# (244), then virtual_quote_reserves as an i128 at 245..261. Live pool
# accounts are 301 bytes (fields end at 261, rest is padding).
("is_mayhem_mode", "u8"),
("is_cashback_coin", "u8"),
]
for field_name, field_type in fields:
@@ -364,23 +373,54 @@ async def get_pumpswap_fee_recipients(
# ============================================================================
async def read_virtual_quote_reserves(client: AsyncClient, pool: Pubkey) -> int:
"""Read Pool::virtual_quote_reserves, the field appended after the flags.
Args:
client: Solana RPC client
pool: Pool (market) address
Returns:
Raw virtual quote reserves, or 0 if the account predates the field
"""
response = await client.get_account_info(pool, encoding="base64")
if not response.value or not response.value.data:
return 0
data = response.value.data
end = POOL_VIRTUAL_QUOTE_RESERVES_OFFSET + POOL_VIRTUAL_QUOTE_RESERVES_SIZE
if len(data) < end:
return 0
return int.from_bytes(
data[POOL_VIRTUAL_QUOTE_RESERVES_OFFSET : end], "little", signed=True
)
async def calculate_token_pool_price(
client: AsyncClient,
pool_base_token_account: Pubkey,
pool_quote_token_account: Pubkey,
virtual_quote_reserves: int = 0,
) -> float:
"""Calculate current token price from AMM pool balances.
"""Calculate current token price from AMM pool reserves.
AMM price is determined by the ratio of tokens in the pool:
price = quote_balance / base_balance
Price is the ratio of *effective* quote reserves to base reserves:
effective_quote_reserves =
pool_quote_token_account.amount + Pool::virtual_quote_reserves
PumpSwap added `virtual_quote_reserves` to the Pool account. Upstream's
release note says it is 0 on every pool, but that is out of date: live pools
carry non-zero values (17.58 SOL observed on a 148 SOL pool, i.e. quoting
off the raw vault balance under-prices by ~10.6%). Always add it.
Args:
client: Solana RPC client
pool_base_token_account: Pool's token account (the token being priced)
pool_quote_token_account: Pool's SOL account (the quote currency)
pool_quote_token_account: Pool's quote account
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in SOL per token
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(pool_base_token_account)
quote_balance_resp = await client.get_token_account_balance(
@@ -388,7 +428,9 @@ async def calculate_token_pool_price(
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_amount = float(quote_balance_resp.value.ui_amount)
quote_decimals = int(quote_balance_resp.value.decimals)
quote_raw = int(quote_balance_resp.value.amount) + int(virtual_quote_reserves)
quote_amount = quote_raw / 10**quote_decimals
return quote_amount / base_amount
@@ -462,7 +504,10 @@ async def buy_pump_swap(
"""
token_program_id = await get_token_program_id(client, base_mint)
token_price_sol = await calculate_token_pool_price(
client, pool_base_token_account, pool_quote_token_account
client,
pool_base_token_account,
pool_quote_token_account,
await read_virtual_quote_reserves(client, market),
)
print(f"Token price in SOL: {token_price_sol:.10f} SOL")
@@ -92,7 +92,11 @@ BREAKING_FEE_RECIPIENTS = [
POOL_DISCRIMINATOR_SIZE = 8
POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data
POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored
POOL_IS_CASHBACK_OFFSET = 244 # Where is_cashback_coin flag is stored
POOL_IS_CASHBACK_OFFSET = 244
# virtual_quote_reserves is an i128 appended after the flags. Pool fields
# end at 261; live accounts are 301 bytes with trailing padding.
POOL_VIRTUAL_QUOTE_RESERVES_OFFSET = 245
POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
@@ -176,6 +180,11 @@ async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
("pool_quote_token_account", "pubkey"),
("lp_supply", "u64"),
("coin_creator", "pubkey"),
# Appended after coin_creator: is_mayhem_mode (243), is_cashback_coin
# (244), then virtual_quote_reserves as an i128 at 245..261. Live pool
# accounts are 301 bytes (fields end at 261, rest is padding).
("is_mayhem_mode", "u8"),
("is_cashback_coin", "u8"),
]
for field_name, field_type in fields:
@@ -328,23 +337,54 @@ async def get_pumpswap_fee_recipients(
# ============================================================================
async def read_virtual_quote_reserves(client: AsyncClient, pool: Pubkey) -> int:
"""Read Pool::virtual_quote_reserves, the field appended after the flags.
Args:
client: Solana RPC client
pool: Pool (market) address
Returns:
Raw virtual quote reserves, or 0 if the account predates the field
"""
response = await client.get_account_info(pool, encoding="base64")
if not response.value or not response.value.data:
return 0
data = response.value.data
end = POOL_VIRTUAL_QUOTE_RESERVES_OFFSET + POOL_VIRTUAL_QUOTE_RESERVES_SIZE
if len(data) < end:
return 0
return int.from_bytes(
data[POOL_VIRTUAL_QUOTE_RESERVES_OFFSET : end], "little", signed=True
)
async def calculate_token_pool_price(
client: AsyncClient,
pool_base_token_account: Pubkey,
pool_quote_token_account: Pubkey,
virtual_quote_reserves: int = 0,
) -> float:
"""Calculate current token price from AMM pool balances.
"""Calculate current token price from AMM pool reserves.
AMM price is determined by the ratio of tokens in the pool:
price = quote_balance / base_balance
Price is the ratio of *effective* quote reserves to base reserves:
effective_quote_reserves =
pool_quote_token_account.amount + Pool::virtual_quote_reserves
PumpSwap added `virtual_quote_reserves` to the Pool account. Upstream's
release note says it is 0 on every pool, but that is out of date: live pools
carry non-zero values (17.58 SOL observed on a 148 SOL pool, i.e. quoting
off the raw vault balance under-prices by ~10.6%). Always add it.
Args:
client: Solana RPC client
pool_base_token_account: Pool's token account (the token being priced)
pool_quote_token_account: Pool's SOL account (the quote currency)
pool_quote_token_account: Pool's quote account
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in SOL per token
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(pool_base_token_account)
quote_balance_resp = await client.get_token_account_balance(
@@ -352,7 +392,9 @@ async def calculate_token_pool_price(
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_amount = float(quote_balance_resp.value.ui_amount)
quote_decimals = int(quote_balance_resp.value.decimals)
quote_raw = int(quote_balance_resp.value.amount) + int(virtual_quote_reserves)
quote_amount = quote_raw / 10**quote_decimals
return quote_amount / base_amount
@@ -469,7 +511,10 @@ async def sell_pump_swap(
return None
token_price_sol = await calculate_token_pool_price(
client, pool_base_token_account, pool_quote_token_account
client,
pool_base_token_account,
pool_quote_token_account,
await read_virtual_quote_reserves(client, market),
)
print(f"Price per Token: {token_price_sol:.20f} SOL")
+230
View File
@@ -0,0 +1,230 @@
"""Dry-run the bot's real buy path against a freshly created coin.
Detects a new pump.fun coin with the bot's own listener, runs the same
PlatformAwareBuyer code the bot uses (including the extreme_fast_mode curve
refresh), but intercepts the transaction just before submission and simulates it
instead. This exercises the listener -> event parser -> curve manager ->
address provider -> instruction builder chain as a unit.
No funds move: `build_and_send_transaction` is monkeypatched to simulate.
Usage:
uv run learning-examples/simulate_bot_buy_path.py
uv run learning-examples/simulate_bot_buy_path.py --no-extreme-fast
"""
import asyncio
import os
import sys
from base64 import b64encode
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price # noqa: E402
from solders.message import Message # noqa: E402
from solders.transaction import Transaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
# Matches retries.wait_after_creation in the bot configs. Only used when
# extreme_fast_mode is off, where the buyer reads the curve at `confirmed`.
CURVE_STABILIZE_SECONDS = 15
async def wait_for_token(timeout_seconds: float = 90.0) -> TokenInfo | None:
"""Wait for the bot's geyser listener to report a new coin.
Args:
timeout_seconds: How long to wait
Returns:
The first TokenInfo seen, or None on timeout
"""
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(timeout_seconds / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0] if seen else None
def install_simulation_hook(client: SolanaClient) -> dict:
"""Replace transaction submission with simulation.
Args:
client: Client whose send path should be intercepted
Returns:
Dict that will be populated with the simulation outcome
"""
outcome: dict = {}
async def simulate_instead(
instructions,
signer_keypair,
skip_preflight=True,
max_retries=3,
priority_fee=None,
compute_unit_limit=None,
account_data_size_limit=None,
):
preamble = []
if compute_unit_limit:
preamble.append(set_compute_unit_limit(compute_unit_limit))
if priority_fee:
preamble.append(set_compute_unit_price(priority_fee))
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[*preamble, *instructions], signer_keypair.pubkey(), blockhash
)
transaction = Transaction([signer_keypair], message, blockhash)
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "simulateTransaction",
"params": [
b64encode(bytes(transaction)).decode(),
{
"encoding": "base64",
"sigVerify": False,
"replaceRecentBlockhash": True,
"commitment": "processed",
},
],
}
)
value = (response or {}).get("result", {}).get("value", {})
outcome.update(
{
"err": value.get("err"),
"units": value.get("unitsConsumed"),
"logs": value.get("logs") or [],
"cu_limit": compute_unit_limit,
"priority_fee": priority_fee,
"instruction_count": len(instructions),
"account_count": len(instructions[-1].accounts),
}
)
# Returning a sentinel signature: confirm_transaction is stubbed below.
return "SIMULATED"
async def never_confirm(_signature, **_kwargs):
return False
client.build_and_send_transaction = simulate_instead
client.confirm_transaction = never_confirm
return outcome
async def main() -> int:
"""Run the bot's buy path in simulation mode.
Returns:
Process exit code (0 if the simulated buy had no program error)
"""
extreme_fast = "--no-extreme-fast" not in sys.argv
print("Waiting for a fresh pump.fun coin via the bot's geyser listener...")
token_info = await wait_for_token()
if token_info is None:
print("No coin detected before timeout.")
return 2
print(f"\ndetected: {token_info.symbol} ({token_info.mint})")
print(f"quote_mint (from CreateEvent): {token_info.quote_mint}")
print(f"token program: {token_info.token_program_id}")
print(f"mayhem={token_info.is_mayhem_mode} cashback={token_info.is_cashback_coin}")
print(f"extreme_fast_mode={extreme_fast}\n")
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=1_000_000,
extra_fee=0.0,
hard_cap=1_000_000,
)
outcome = install_simulation_hook(client)
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=extreme_fast,
)
if not extreme_fast:
# Mirror the bot's retries.wait_after_creation pause. Without it the
# curve read races the account's confirmation and fails before any
# instruction is built.
print(f"Waiting {CURVE_STABILIZE_SECONDS}s for the curve to stabilize...")
await asyncio.sleep(CURVE_STABILIZE_SECONDS)
try:
result = await buyer.execute(token_info)
finally:
await client.close()
if not outcome:
print(f"Buy path never reached transaction submission: {result.error_message}")
return 1
print("simulated buy:")
print(f" instructions: {outcome['instruction_count']}")
print(f" trade accounts: {outcome['account_count']}")
print(f" cu_limit: {outcome['cu_limit']}")
print(f" unitsConsumed: {outcome['units']}")
print(f" err: {outcome['err']}")
if outcome["err"]:
for line in outcome["logs"]:
if "Error" in line or "failed" in line or "Instruction:" in line:
print(f" {line}")
return 1
headroom = outcome["cu_limit"] - (outcome["units"] or 0)
print(f"\nCU headroom: {headroom} ({headroom / outcome['cu_limit']:.0%})")
print("Buy path validated end to end against live mainnet state.")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+288
View File
@@ -0,0 +1,288 @@
"""Simulate pump.fun buy_v2 / sell_v2 against mainnet without spending funds.
Builds both instructions through the bot's real code path (address provider,
instruction builder, curve manager) and runs them through
`simulateTransaction`, which executes the program against live state but never
submits. Reports the program error (if any) and the compute units consumed
use the latter to tune `get_buy_compute_unit_limit` / `get_sell_compute_unit_limit`.
The wallet is only used to derive a pubkey and sign nothing: simulation runs
with sigVerify disabled.
Usage:
# simulate against a specific coin
uv run learning-examples/simulate_v2_trades.py <MINT>
# discover a fresh coin via geyser, then simulate against it
uv run learning-examples/simulate_v2_trades.py
"""
import asyncio
import os
import sys
from base64 import b64encode
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.compute_budget import set_compute_unit_limit # noqa: E402
from solders.message import Message # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from solders.transaction import Transaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.pubkeys import ( # noqa: E402
TOKEN_DECIMALS,
SystemAddresses,
normalize_quote_mint,
quote_token_program,
quote_units_per_token,
)
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms import get_platform_implementations # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
# Buy this many whole tokens in the simulation. Small enough that the quote cost
# stays well under a test wallet's balance on a fresh curve.
SIMULATED_TOKEN_AMOUNT = 20
# Generous ceiling so simulation reports true consumption rather than hitting
# the limit. The real bot uses the builder's tuned values.
SIMULATION_CU_LIMIT = 400_000
async def discover_mint(timeout_seconds: float = 45.0) -> Pubkey | None:
"""Listen for a freshly created pump.fun coin via geyser.
Args:
timeout_seconds: How long to wait for a creation event
Returns:
Mint of the first coin seen, or None on timeout
"""
from monitoring.listener_factory import ListenerFactory
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
deadline = timeout_seconds / 0.5
for _ in range(int(deadline)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0].mint if seen else None
async def build_token_info(
mint: Pubkey, client: SolanaClient
) -> tuple[TokenInfo, dict]:
"""Assemble TokenInfo for an existing coin from its on-chain curve state.
Args:
mint: Base token mint
client: Solana RPC client
Returns:
Tuple of (TokenInfo, decoded curve state)
"""
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
provider = implementations.address_provider
curve_manager = implementations.curve_manager
bonding_curve = provider.derive_pool_address(mint)
state = await curve_manager.get_pool_state(bonding_curve, commitment="processed")
creator = state["creator"]
creator = Pubkey.from_string(creator) if isinstance(creator, str) else creator
quote_mint = normalize_quote_mint(state["quote_mint"])
# Which token program owns the base mint decides the base ATA derivation.
account = await client.get_account_info(mint)
base_token_program = (
SystemAddresses.TOKEN_2022_PROGRAM
if str(account.owner) == str(SystemAddresses.TOKEN_2022_PROGRAM)
else SystemAddresses.TOKEN_PROGRAM
)
token_info = TokenInfo(
name="simulation",
symbol="SIM",
uri="",
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=provider.derive_associated_bonding_curve(
mint, bonding_curve, base_token_program
),
creator=creator,
creator_vault=provider.derive_creator_vault(creator),
token_program_id=base_token_program,
is_mayhem_mode=state["is_mayhem_mode"],
is_cashback_coin=state["is_cashback_coin"],
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
)
return token_info, state
async def simulate(
client: SolanaClient, wallet: Wallet, instructions: list, label: str
) -> bool:
"""Run a set of instructions through simulateTransaction and report.
Args:
client: Solana RPC client
wallet: Wallet whose pubkey pays and signs
instructions: Instructions to simulate
label: Human-readable name for output
Returns:
True if the simulation reported no program error
"""
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[set_compute_unit_limit(SIMULATION_CU_LIMIT), *instructions],
wallet.pubkey,
blockhash,
)
transaction = Transaction([wallet.keypair], message, blockhash)
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "simulateTransaction",
"params": [
b64encode(bytes(transaction)).decode(),
{
"encoding": "base64",
"sigVerify": False,
"replaceRecentBlockhash": True,
"commitment": "processed",
},
],
}
)
if not response or "result" not in response:
print(f" {label}: RPC call failed: {response}")
return False
value = response["result"]["value"]
err = value.get("err")
units = value.get("unitsConsumed")
account_count = len(instructions[-1].accounts)
print(f" {label}: accounts={account_count} unitsConsumed={units} err={err}")
if err:
for line in value.get("logs") or []:
if "Error" in line or "failed" in line or "Instruction:" in line:
print(f" {line}")
return err is None
async def main() -> int:
"""Simulate a buy_v2 and sell_v2 for one coin.
Returns:
Process exit code (0 if the buy simulation succeeded)
"""
mint_arg = sys.argv[1] if len(sys.argv) > 1 else None
if mint_arg:
mint = Pubkey.from_string(mint_arg)
else:
print("No mint given; listening for a fresh pump.fun coin via geyser...")
mint = await discover_mint()
if mint is None:
print("No coin seen before timeout. Pass a mint explicitly.")
return 2
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
try:
token_info, state = await build_token_info(mint, client)
quote_mint = token_info.quote_mint
quote_unit = quote_units_per_token(quote_mint)
print(f"\nmint: {mint}")
print(f"bonding_curve: {token_info.bonding_curve}")
print(f"quote_mint: {quote_mint}")
print(f"base program: {token_info.token_program_id}")
print(
f"mayhem={token_info.is_mayhem_mode} "
f"cashback={token_info.is_cashback_coin} "
f"complete={state['complete']}"
)
print(f"price: {state['price_per_token']:.10f} quote/token")
print(f"wallet: {wallet.pubkey}\n")
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
provider = implementations.address_provider
builder = implementations.instruction_builder
token_raw = SIMULATED_TOKEN_AMOUNT * 10**TOKEN_DECIMALS
# Cap the quote spend generously; simulation only needs it affordable.
max_quote_raw = int(
SIMULATED_TOKEN_AMOUNT * state["price_per_token"] * quote_unit * 2
) or int(0.001 * quote_unit)
print("simulating:")
buy_instructions = await builder.build_buy_v2_instruction(
token_info, wallet.pubkey, max_quote_raw, token_raw, provider
)
buy_ok = await simulate(client, wallet, buy_instructions, "buy_v2 ")
sell_instructions = await builder.build_sell_v2_instruction(
token_info, wallet.pubkey, token_raw, 1, provider
)
await simulate(client, wallet, sell_instructions, "sell_v2")
print(
" ^ expected to fail with AccountNotInitialized on "
"associated_base_user unless\n the wallet already holds this "
"coin. That error means all 26 accounts validated."
)
# Buy and sell in one transaction so the position exists mid-tx. This is
# the only way to get a representative sell_v2 CU number without
# actually holding the coin. Sell slightly less than bought to stay
# inside the realised balance.
combined = [*buy_instructions, *sell_instructions[:-1]]
resell_raw = int(token_raw * 0.9)
combined.append(
(
await builder.build_sell_v2_instruction(
token_info, wallet.pubkey, resell_raw, 1, provider
)
)[-1]
)
combined_ok = await simulate(client, wallet, combined, "buy+sell")
if combined_ok:
print(" ^ subtract the buy_v2 figure above to estimate sell_v2 CU")
return 0 if buy_ok else 1
finally:
await client.close()
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,556 @@
"""Cross-check the hardcoded buy_v2/sell_v2 account layouts against the IDL.
The v2 instructions take 27 and 26 mandatory accounts in a fixed order. Getting
one position or writability flag wrong produces an on-chain failure that is
awkward to debug, so this script diffs the layouts in
`platforms.pumpfun.instruction_builder` against `idl/pump_fun_idl.json` and also
recomputes every PDA/ATA the address provider derives, comparing each against
the seeds declared in the IDL.
Runs entirely offline no RPC, no keys, no transactions.
Usage:
uv run learning-examples/verify_v2_account_layout.py
"""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "learning-examples"))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from spl.token.instructions import get_associated_token_address # noqa: E402
from core.pubkeys import USDC_MINT, WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms.pumpfun.address_provider import ( # noqa: E402
PumpFunAddresses,
PumpFunAddressProvider,
)
from platforms.pumpfun.instruction_builder import ( # noqa: E402
_BUY_V2_ACCOUNTS,
_SELL_V2_ACCOUNTS,
)
IDL_PATH = PROJECT_ROOT / "idl" / "pump_fun_idl.json"
def load_idl_accounts(name: str) -> list[dict]:
"""Get the IDL account list for an instruction.
Args:
name: Instruction name
Returns:
List of IDL account definitions
"""
idl = json.loads(IDL_PATH.read_text())
for instruction in idl["instructions"]:
if instruction["name"] == name:
return instruction["accounts"]
raise KeyError(f"Instruction {name} not present in {IDL_PATH}")
def check_layout(name: str, layout: list[tuple[str, bool]]) -> list[str]:
"""Compare a hardcoded layout against the IDL's account list.
Args:
name: Instruction name
layout: Ordered (account name, is_writable) pairs from our builder
Returns:
List of human-readable problems (empty if the layout matches)
"""
idl_accounts = load_idl_accounts(name)
problems = []
if len(layout) != len(idl_accounts):
problems.append(
f"{name}: account count {len(layout)} != IDL {len(idl_accounts)}"
)
for index, (idl_account, ours) in enumerate(zip(idl_accounts, layout), start=1):
our_name, our_writable = ours
if idl_account["name"] != our_name:
problems.append(
f"{name}[{index}]: name {our_name!r} != IDL {idl_account['name']!r}"
)
idl_writable = bool(idl_account.get("writable"))
if idl_writable != our_writable:
problems.append(
f"{name}[{index}] {our_name}: writable={our_writable} "
f"!= IDL writable={idl_writable}"
)
if idl_account.get("signer") and our_name != "user":
problems.append(
f"{name}[{index}] {our_name}: IDL marks this a signer but only "
f"`user` is expected to sign"
)
return problems
def build_token_info(quote_mint: Pubkey, *, mayhem: bool) -> TokenInfo:
"""Construct a TokenInfo for a synthetic coin.
Args:
quote_mint: Quote mint to pair the coin against
mayhem: Whether the coin is in mayhem mode
Returns:
TokenInfo suitable for driving the address provider
"""
provider = PumpFunAddressProvider()
# Fixed, arbitrary mint/creator so results are reproducible.
mint = Pubkey.from_string("CU7nUQaJ4beyYjC3xAUrh5RiSjw14fhU6oWTwRBse8gj")
creator = Pubkey.from_string("5wyFsNExysbXf2hTtcn8Tqd3urs9Nv85Zx1zNdAfTMmX")
bonding_curve = provider.derive_pool_address(mint)
return TokenInfo(
name="layout-check",
symbol="CHK",
uri="",
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=provider.derive_associated_bonding_curve(
mint, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM
),
creator=creator,
creator_vault=provider.derive_creator_vault(creator),
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
is_mayhem_mode=mayhem,
quote_mint=quote_mint,
)
def check_derivations(quote_mint: Pubkey, *, mayhem: bool) -> list[str]:
"""Independently recompute every derived v2 account and compare.
Args:
quote_mint: Quote mint to pair the coin against
mayhem: Whether the coin is in mayhem mode
Returns:
List of mismatches (empty if all derivations agree)
"""
provider = PumpFunAddressProvider()
token_info = build_token_info(quote_mint, mayhem=mayhem)
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
accounts = provider.get_buy_v2_instruction_accounts(token_info, user)
pump = PumpFunAddresses.PROGRAM
fee_program = PumpFunAddresses.FEE_PROGRAM
quote_program = SystemAddresses.TOKEN_PROGRAM
base_program = SystemAddresses.TOKEN_2022_PROGRAM
mint = token_info.mint
bonding_curve = token_info.bonding_curve
creator_vault = token_info.creator_vault
uva, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)], pump
)
expected = {
"bonding_curve": Pubkey.find_program_address(
[b"bonding-curve", bytes(mint)], pump
)[0],
"creator_vault": Pubkey.find_program_address(
[b"creator-vault", bytes(token_info.creator)], pump
)[0],
"sharing_config": Pubkey.find_program_address(
[b"sharing-config", bytes(mint)], fee_program
)[0],
"global_volume_accumulator": Pubkey.find_program_address(
[b"global_volume_accumulator"], pump
)[0],
"user_volume_accumulator": uva,
"fee_config": Pubkey.find_program_address(
[b"fee_config", bytes(pump)], fee_program
)[0],
"event_authority": Pubkey.find_program_address([b"__event_authority"], pump)[0],
"associated_base_bonding_curve": get_associated_token_address(
bonding_curve, mint, base_program
),
"associated_quote_bonding_curve": get_associated_token_address(
bonding_curve, quote_mint, quote_program
),
"associated_base_user": get_associated_token_address(user, mint, base_program),
"associated_quote_user": get_associated_token_address(
user, quote_mint, quote_program
),
"associated_creator_vault": get_associated_token_address(
creator_vault, quote_mint, quote_program
),
"associated_quote_fee_recipient": get_associated_token_address(
accounts["fee_recipient"], quote_mint, quote_program
),
"associated_quote_buyback_fee_recipient": get_associated_token_address(
accounts["buyback_fee_recipient"], quote_mint, quote_program
),
"associated_user_volume_accumulator": get_associated_token_address(
uva, quote_mint, quote_program
),
"quote_mint": quote_mint,
"base_mint": mint,
"user": user,
"program": pump,
"fee_program": fee_program,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"associated_token_program": SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
"base_token_program": base_program,
"quote_token_program": quote_program,
"global": PumpFunAddresses.GLOBAL,
}
problems = [
f"{key}: provider={accounts[key]} != expected={value}"
for key, value in expected.items()
if accounts[key] != value
]
# The fee recipient must come from the right documented set.
recipient_set = (
PumpFunAddresses.RESERVED_FEE_RECIPIENTS
if mayhem
else PumpFunAddresses.NORMAL_FEE_RECIPIENTS
)
if accounts["fee_recipient"] not in recipient_set:
problems.append(
f"fee_recipient {accounts['fee_recipient']} not in "
f"{'reserved' if mayhem else 'normal'} fee recipient set"
)
if accounts["buyback_fee_recipient"] not in PumpFunAddresses.BUYBACK_FEE_RECIPIENTS:
problems.append(
f"buyback_fee_recipient {accounts['buyback_fee_recipient']} not in "
f"buyback fee recipient set"
)
# Every account must be distinct except where the program expects reuse.
if len(set(accounts.values())) != len(accounts):
duplicates = [
key
for key, value in accounts.items()
if list(accounts.values()).count(value) > 1
]
problems.append(f"duplicate account addresses for: {sorted(duplicates)}")
return problems
def check_instruction_encoding() -> list[str]:
"""Build real v2 instructions and assert their shape and data encoding.
Covers the USDC path, which cannot be exercised on-chain until a
USDC-paired coin exists.
Returns:
List of problems (empty if the instructions are well-formed)
"""
import asyncio
import struct
from interfaces.core import Platform as _Platform # noqa: F401
from platforms.pumpfun.instruction_builder import PumpFunInstructionBuilder
from utils.idl_manager import get_idl_manager
provider = PumpFunAddressProvider()
builder = PumpFunInstructionBuilder(get_idl_manager().get_parser(Platform.PUMP_FUN))
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
problems = []
for quote_mint, label, expect_quote_ata in (
(WSOL_MINT, "SOL-paired", False),
(USDC_MINT, "USDC-paired", True),
):
token_info = build_token_info(quote_mint, mayhem=False)
buy = asyncio.run(
builder.build_buy_v2_instruction(
token_info, user, 1_500_000, 20_000_000, provider
)
)
sell = asyncio.run(
builder.build_sell_v2_instruction(
token_info, user, 20_000_000, 900_000, provider
)
)
# Instruction counts: base ATA always, quote ATA only for non-SOL.
expected_buy_ix = 3 if expect_quote_ata else 2
if len(buy) != expected_buy_ix:
problems.append(
f"{label}: buy produced {len(buy)} instructions, "
f"expected {expected_buy_ix}"
)
expected_sell_ix = 2 if expect_quote_ata else 1
if len(sell) != expected_sell_ix:
problems.append(
f"{label}: sell produced {len(sell)} instructions, "
f"expected {expected_sell_ix}"
)
if len(buy[-1].accounts) != 27:
problems.append(
f"{label}: buy_v2 has {len(buy[-1].accounts)} accounts != 27"
)
if len(sell[-1].accounts) != 26:
problems.append(
f"{label}: sell_v2 has {len(sell[-1].accounts)} accounts != 26"
)
# buy_v2 data: 8-byte discriminator + amount (tokens) + max_sol_cost.
# No trailing track_volume OptionBool, unlike the legacy buy.
buy_data = bytes(buy[-1].data)
if len(buy_data) != 24:
problems.append(
f"{label}: buy_v2 data is {len(buy_data)} bytes, expected 24 "
f"(discriminator + 2 u64, no track_volume)"
)
else:
amount, max_cost = struct.unpack("<QQ", buy_data[8:])
if amount != 20_000_000 or max_cost != 1_500_000:
problems.append(
f"{label}: buy_v2 args decoded as amount={amount}, "
f"max_sol_cost={max_cost}; expected 20000000 and 1500000"
)
sell_data = bytes(sell[-1].data)
if len(sell_data) != 24:
problems.append(
f"{label}: sell_v2 data is {len(sell_data)} bytes, expected 24"
)
else:
amount, min_out = struct.unpack("<QQ", sell_data[8:])
if amount != 20_000_000 or min_out != 900_000:
problems.append(
f"{label}: sell_v2 args decoded as amount={amount}, "
f"min_sol_output={min_out}; expected 20000000 and 900000"
)
# Exactly one signer, and it must be the user.
signers = [meta.pubkey for meta in buy[-1].accounts if meta.is_signer]
if signers != [user]:
problems.append(f"{label}: buy_v2 signers {signers} != [{user}]")
return problems
def check_quote_config() -> list[str]:
"""Assert quote-amount config resolution and alias handling.
Returns:
List of problems (empty if config resolution behaves correctly)
"""
from core.pubkeys import resolve_quote_amounts, resolve_quote_mint
from trading.universal_trader import _resolve_quote_config
problems = []
if resolve_quote_mint("usdc") != USDC_MINT:
problems.append("alias 'usdc' did not resolve to the USDC mint")
if resolve_quote_mint("sol") != WSOL_MINT:
problems.append("alias 'sol' did not resolve to wrapped SOL")
if resolve_quote_mint(str(USDC_MINT)) != USDC_MINT:
problems.append("raw USDC mint string did not resolve")
try:
resolve_quote_amounts({"usdc": 0})
problems.append("resolve_quote_amounts accepted a zero amount")
except ValueError:
pass
try:
resolve_quote_mint("not-a-mint")
problems.append("resolve_quote_mint accepted an invalid mint")
except ValueError:
pass
# SOL always present from buy_amount; USDC only when configured.
amounts, allowed = _resolve_quote_config(0.0001, None, None)
if amounts.get(WSOL_MINT) != 0.0001:
problems.append("SOL amount did not fall back to buy_amount")
if USDC_MINT in amounts:
problems.append("USDC present in amounts without being configured")
if allowed is not None:
problems.append("allowed_quote_mints should be None when unset")
amounts, allowed = _resolve_quote_config(0.0001, {"usdc": 5.0}, ["sol", "usdc"])
if amounts.get(USDC_MINT) != 5.0:
problems.append("configured USDC amount not resolved")
if allowed != {WSOL_MINT, USDC_MINT}:
problems.append(f"allowed_quote_mints resolved to {allowed}")
return problems
def check_examples_toolkit() -> list[str]:
"""Check learning-examples/pump_v2.py agrees with the IDL and with src/.
The examples carry their own standalone copy of the v2 layout so they stay
readable without importing src/. That copy is exactly the kind of thing that
silently drifts, so diff it against both sources of truth.
Returns:
List of problems (empty if the toolkit agrees)
"""
import pump_v2
from platforms.pumpfun.address_provider import PumpFunAddresses
problems = []
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
mint = Pubkey.from_string("CU7nUQaJ4beyYjC3xAUrh5RiSjw14fhU6oWTwRBse8gj")
creator = Pubkey.from_string("5wyFsNExysbXf2hTtcn8Tqd3urs9Nv85Zx1zNdAfTMmX")
# Fee recipient sets must match src/ exactly.
for label, theirs, ours in (
(
"normal",
pump_v2.NORMAL_FEE_RECIPIENTS,
PumpFunAddresses.NORMAL_FEE_RECIPIENTS,
),
(
"reserved",
pump_v2.RESERVED_FEE_RECIPIENTS,
PumpFunAddresses.RESERVED_FEE_RECIPIENTS,
),
(
"buyback",
pump_v2.BUYBACK_FEE_RECIPIENTS,
PumpFunAddresses.BUYBACK_FEE_RECIPIENTS,
),
):
if theirs != ours:
problems.append(f"pump_v2 {label} fee recipients differ from src/")
# Discriminators must match the IDL.
idl = json.loads(IDL_PATH.read_text())
by_name = {i["name"]: i for i in idl["instructions"]}
for name, disc in (
("buy_v2", pump_v2.BUY_V2_DISCRIMINATOR),
("sell_v2", pump_v2.SELL_V2_DISCRIMINATOR),
):
expected = bytes(by_name[name]["discriminator"])
if disc != expected:
problems.append(
f"pump_v2 {name} discriminator {list(disc)} != IDL {list(expected)}"
)
# Account lists must match the IDL in order and writability, for both
# quote assets and both mayhem states.
for quote_mint, label in (
(pump_v2.WSOL_MINT, "SOL"),
(pump_v2.USDC_MINT, "USDC"),
):
for mayhem in (False, True):
for name, builder in (
("buy_v2", pump_v2.build_buy_v2_instruction),
("sell_v2", pump_v2.build_sell_v2_instruction),
):
kwargs = {
"base_mint": mint,
"creator": creator,
"user": user,
"quote_mint": quote_mint,
"is_mayhem_mode": mayhem,
}
if name == "buy_v2":
instruction = builder(
token_amount_raw=1, max_quote_cost_raw=2, **kwargs
)
else:
instruction = builder(
token_amount_raw=1, min_quote_output_raw=2, **kwargs
)
idl_accounts = by_name[name]["accounts"]
if len(instruction.accounts) != len(idl_accounts):
problems.append(
f"pump_v2 {name} ({label}, mayhem={mayhem}): "
f"{len(instruction.accounts)} accounts != IDL "
f"{len(idl_accounts)}"
)
continue
for index, (meta, idl_account) in enumerate(
zip(instruction.accounts, idl_accounts), start=1
):
if meta.is_writable != bool(idl_account.get("writable")):
problems.append(
f"pump_v2 {name}[{index}] {idl_account['name']} "
f"({label}, mayhem={mayhem}): writable "
f"{meta.is_writable} != IDL "
f"{bool(idl_account.get('writable'))}"
)
# Cross-check every address against the src/ provider, which the
# checks above already validated.
provider = PumpFunAddressProvider()
token_info = build_token_info(quote_mint, mayhem=mayhem)
resolved = (
provider.get_buy_v2_instruction_accounts(token_info, user)
if name == "buy_v2"
else provider.get_sell_v2_instruction_accounts(token_info, user)
)
layout = _BUY_V2_ACCOUNTS if name == "buy_v2" else _SELL_V2_ACCOUNTS
for (account_name, _), meta in zip(layout, instruction.accounts):
# Fee recipients are picked at random from a set, so compare
# membership rather than identity.
if "fee_recipient" in account_name:
continue
if resolved[account_name] != meta.pubkey:
problems.append(
f"pump_v2 {name} {account_name} ({label}, "
f"mayhem={mayhem}): {meta.pubkey} != src/ "
f"{resolved[account_name]}"
)
return problems
def main() -> int:
"""Run all layout and derivation checks.
Returns:
Process exit code (0 on success)
"""
all_problems = []
for name, layout in (("buy_v2", _BUY_V2_ACCOUNTS), ("sell_v2", _SELL_V2_ACCOUNTS)):
problems = check_layout(name, layout)
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"{name}: {len(layout)} accounts vs IDL -> {status}")
all_problems.extend(problems)
for quote_mint, label in ((WSOL_MINT, "SOL-paired"), (USDC_MINT, "USDC-paired")):
for mayhem in (False, True):
problems = check_derivations(quote_mint, mayhem=mayhem)
tag = f"{label}, mayhem={mayhem}"
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"derivations ({tag}) -> {status}")
all_problems.extend(problems)
for name, check in (
("instruction encoding", check_instruction_encoding),
("quote config resolution", check_quote_config),
("learning-examples pump_v2 toolkit", check_examples_toolkit),
):
problems = check()
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"{name} -> {status}")
all_problems.extend(problems)
if all_problems:
print("\nProblems found:")
for problem in all_problems:
print(f" - {problem}")
return 1
print("\nAll v2 account layouts and derivations match the IDL.")
return 0
if __name__ == "__main__":
sys.exit(main())